From 7bfcece1d9dc956736d8bae67639136344dbd8d9 Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Wed, 22 Jul 2026 14:22:58 -0700 Subject: [PATCH 1/7] Add Discord host for the weather + WorkIQ agent Discord has no Azure Bot Service channel, so this hosts the agent directly with Discord.Net (Gateway). Incoming messages drive an Agent Framework ChatClientAgent (weather tools + WorkIQ MCP tools via dev bearer token to mcp_TeamsServer); replies render as Discord embeds. Per-channel conversation sessions. Bot token + WorkIQ token via user-secrets/env. --- .../dotnet/discordagent/DiscordAgent.csproj | 34 +++ samples/dotnet/discordagent/Program.cs | 206 ++++++++++++++++++ .../dotnet/discordagent/Tools/DateTimeTool.cs | 15 ++ .../dotnet/discordagent/Tools/WeatherTool.cs | 76 +++++++ 4 files changed, 331 insertions(+) create mode 100644 samples/dotnet/discordagent/DiscordAgent.csproj create mode 100644 samples/dotnet/discordagent/Program.cs create mode 100644 samples/dotnet/discordagent/Tools/DateTimeTool.cs create mode 100644 samples/dotnet/discordagent/Tools/WeatherTool.cs diff --git a/samples/dotnet/discordagent/DiscordAgent.csproj b/samples/dotnet/discordagent/DiscordAgent.csproj new file mode 100644 index 00000000..b7f10a95 --- /dev/null +++ b/samples/dotnet/discordagent/DiscordAgent.csproj @@ -0,0 +1,34 @@ + + + + Exe + net8.0 + enable + enable + discord-agent-vinchen + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/dotnet/discordagent/Program.cs b/samples/dotnet/discordagent/Program.cs new file mode 100644 index 00000000..b470013b --- /dev/null +++ b/samples/dotnet/discordagent/Program.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text; +using Azure; +using Azure.AI.OpenAI; +using Discord; +using Discord.WebSocket; +using DiscordAgent.Tools; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using ModelContextProtocol.Client; + +// Discord host for the weather (+ WorkIQ, next step) agent. +// +// Discord has no first-party Azure Bot Service channel, so we connect directly to the +// Discord Gateway with Discord.Net and drive the Agent Framework agent ourselves: +// Discord message -> agent.RunStreamingAsync -> reply as a Discord embed (Discord's card). + +var config = new ConfigurationBuilder() + .AddUserSecrets(typeof(Program).Assembly) + .AddEnvironmentVariables() + .Build(); + +// Bot token from user-secret "Discord:BotToken" or env var DISCORD_BOT_TOKEN. +var token = config["Discord:BotToken"] ?? Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); +if (string.IsNullOrWhiteSpace(token)) +{ + Console.Error.WriteLine( + "Missing Discord bot token. Set it with:\n" + + " dotnet user-secrets set \"Discord:BotToken\" \"\"\n" + + "or the DISCORD_BOT_TOKEN environment variable."); + return; +} + +// ---- Build the agent (same stack as the weather sample) ---- +var endpoint = config["AIServices:AzureOpenAI:Endpoint"]; +var apiKey = config["AIServices:AzureOpenAI:ApiKey"]; +var deployment = config["AIServices:AzureOpenAI:DeploymentName"]; +var openWeatherApiKey = config["OpenWeatherApiKey"]; + +if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey) || + string.IsNullOrWhiteSpace(deployment) || string.IsNullOrWhiteSpace(openWeatherApiKey)) +{ + Console.Error.WriteLine( + "Missing AI/weather config. Set user-secrets:\n" + + " AIServices:AzureOpenAI:Endpoint, AIServices:AzureOpenAI:ApiKey, AIServices:AzureOpenAI:DeploymentName, OpenWeatherApiKey"); + return; +} + +const string instructions = """ + You are a friendly feline assistant. You always speak like a cat (use "meow", playful cat puns, and emojis when they fit). + + You can help with two kinds of requests, and you must always pick the right tool: + 1. United States weather -- use your weather tools: + - current-weather tool for current conditions (temperature, low/high, wind, humidity, short description). + - forecast tool for the next 5 days (date, high/low, short description). + - date tool to resolve "today". Location is a city name; resolve 2-letter US state codes to the full US state name. + 2. Anything that is NOT United States weather -- use the WorkIQ tools (Microsoft 365 / Teams: chats, channels, teams, messages). + + Routing rule: US weather questions go to the weather tools; every other question goes to the WorkIQ tools. + Format answers nicely in markdown, keep them easy to read, and always speak like a cat. Use emojis if it fits! + """; + +IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey)) + .GetChatClient(deployment) + .AsIChatClient(); + +var weatherTool = new WeatherTool(openWeatherApiKey); +var tools = new List +{ + AIFunctionFactory.Create(DateTimeTool.GetDate), + AIFunctionFactory.Create(weatherTool.GetCurrentWeatherForLocation), + AIFunctionFactory.Create(weatherTool.GetWeatherForecastForLocation), +}; + +// ---- WorkIQ (Agent 365) MCP tools ---- +// Discord has no OBO sign-in channel, so we use a dev bearer token for the Teams MCP server. +// Token from user-secret "WorkIQ:McpTeamsServerToken" or env BEARER_TOKEN_MCP_TEAMSSERVER +// (refresh with: a365 develop get-token ...). If absent, the agent runs weather-only. +var mcpToken = config["WorkIQ:McpTeamsServerToken"] ?? Environment.GetEnvironmentVariable("BEARER_TOKEN_MCP_TEAMSSERVER"); +if (!string.IsNullOrWhiteSpace(mcpToken)) +{ + try + { + var mcpTransport = new SseClientTransport(new SseClientTransportOptions + { + Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer"), + AdditionalHeaders = new Dictionary { ["Authorization"] = $"Bearer {mcpToken}" }, + TransportMode = HttpTransportMode.AutoDetect, + Name = "mcp_TeamsServer", + }); + var mcpClient = await McpClientFactory.CreateAsync(mcpTransport); + var mcpTools = await mcpClient.ListToolsAsync(); + tools.AddRange(mcpTools); + Console.WriteLine($"WorkIQ MCP tools loaded from mcp_TeamsServer: {mcpTools.Count}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"WorkIQ MCP tools failed to load (continuing weather-only): {ex.Message}"); + } +} +else +{ + Console.WriteLine("No WorkIQ MCP token (BEARER_TOKEN_MCP_TEAMSSERVER) - running weather-only."); +} + +AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions +{ + Name = "Purrfect Weather Agent", + ChatOptions = new ChatOptions + { + Temperature = 0.2f, + Tools = tools, + Instructions = instructions, + AllowMultipleToolCalls = true, + }, +}); + +// One conversation session per Discord channel, so context is preserved within a channel. +var sessions = new ConcurrentDictionary(); + +// ---- Discord wiring ---- +var socketConfig = new DiscordSocketConfig +{ + // MessageContent is a privileged intent - enable it in the Discord Developer Portal + // (Bot -> Privileged Gateway Intents -> Message Content Intent). + GatewayIntents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.DirectMessages | + GatewayIntents.MessageContent, + LogLevel = LogSeverity.Info +}; + +var client = new DiscordSocketClient(socketConfig); + +client.Log += msg => +{ + Console.WriteLine(msg.ToString()); + return Task.CompletedTask; +}; + +client.Ready += () => +{ + Console.WriteLine($"Discord bot ready as {client.CurrentUser}"); + return Task.CompletedTask; +}; + +client.MessageReceived += async (SocketMessage message) => +{ + // Only handle real user messages; ignore system messages and other bots (incl. ourselves). + if (message is not SocketUserMessage userMessage) return; + if (message.Author.IsBot) return; + + var userText = message.Content?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(userText)) return; + + try + { + using (userMessage.Channel.EnterTypingState()) + { + var session = sessions.GetOrAdd(message.Channel.Id, _ => agent.CreateSessionAsync().GetAwaiter().GetResult()); + + // Run the agent and collect the full answer. + var sb = new StringBuilder(); + await foreach (var update in agent.RunStreamingAsync(userText, session)) + { + if (update.Role == ChatRole.Assistant && !string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + } + } + + var answer = sb.ToString(); + if (string.IsNullOrWhiteSpace(answer)) answer = "_(no response)_"; + if (answer.Length > 4000) answer = answer[..4000] + "…"; + + // Discord embed = Discord's equivalent of a Slack Block Kit card. + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithDescription(answer) + .WithFooter("Agent Framework + WorkIQ · Discord adapter") + .WithCurrentTimestamp() + .Build(); + + await userMessage.Channel.SendMessageAsync(embed: embed); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error handling message: {ex}"); + await userMessage.Channel.SendMessageAsync("Sorry, I hit an error while processing that. 🐾"); + } +}; + +await client.LoginAsync(TokenType.Bot, token); +await client.StartAsync(); + +Console.WriteLine("Discord agent host started. Press Ctrl+C to exit."); + +// Keep the process running. +await Task.Delay(Timeout.Infinite); diff --git a/samples/dotnet/discordagent/Tools/DateTimeTool.cs b/samples/dotnet/discordagent/Tools/DateTimeTool.cs new file mode 100644 index 00000000..91f2d42d --- /dev/null +++ b/samples/dotnet/discordagent/Tools/DateTimeTool.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; + +namespace DiscordAgent.Tools; + +/// +/// Simple date/time tool so the agent can resolve "today" for forecasts. +/// +public static class DateTimeTool +{ + [Description("Gets the current date and time (UTC).")] + public static string GetDate() => DateTimeOffset.UtcNow.ToString("f"); +} diff --git a/samples/dotnet/discordagent/Tools/WeatherTool.cs b/samples/dotnet/discordagent/Tools/WeatherTool.cs new file mode 100644 index 00000000..acc93e9c --- /dev/null +++ b/samples/dotnet/discordagent/Tools/WeatherTool.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using OpenWeatherMapSharp; +using OpenWeatherMapSharp.Models; + +namespace DiscordAgent.Tools; + +/// +/// Weather lookup tools for the Discord host. Unlike the Bot Framework sample's +/// WeatherLookupTool, this version has no ITurnContext dependency (Discord has no +/// Bot Framework turn), so it just calls OpenWeatherMap and returns the data. +/// +public class WeatherTool(string openWeatherApiKey) +{ + [Description("Retrieves the current weather for a location; location is a US city name and state is the full US state name.")] + public async Task GetCurrentWeatherForLocation(string location, string state) + { + Console.WriteLine($"[weather] current weather for {location}, {state}"); + + var openWeather = new OpenWeatherMapService(openWeatherApiKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); + if (openWeatherLocation is { IsSuccess: true }) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + var weather = await openWeather.GetWeatherAsync( + locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + return weather.Response; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); + } + + return null; + } + + [Description("Retrieves the 5-day weather forecast for a location; location is a US city name and state is the full US state name.")] + public async Task?> GetWeatherForecastForLocation(string location, string state) + { + Console.WriteLine($"[weather] forecast for {location}, {state}"); + + var openWeather = new OpenWeatherMapService(openWeatherApiKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); + if (openWeatherLocation is { IsSuccess: true }) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + var weather = await openWeather.GetForecastAsync( + locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + return weather.Response.Items; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); + } + + return null; + } +} From 0406855d1138c74aaf902f2f21826dd62a1b1243 Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Sat, 25 Jul 2026 20:41:16 -0700 Subject: [PATCH 2/7] Refactor Discord into a custom ChannelAdapter reusing the shared WeatherAgent Instead of a standalone Discord host with its own agent, Discord now goes through a DiscordAdapter : ChannelAdapter (modeled on the SDK A2AAdapter): ProcessActivityAsync builds a TurnContext + RunPipelineAsync to drive the SAME WeatherAgent (AgentApplication); SendActivitiesAsync renders replies as Discord embeds. A DiscordGatewayService (BackgroundService) subscribes to the Discord gateway MessageReceived event, converts messages to Activities, and calls the adapter. Slack and Discord now share one agent (weather + WorkIQ), only the adapter differs. --- .../Adapters/DiscordAdapter.cs | 109 +++++++++++++++ .../Adapters/DiscordGatewayService.cs | 129 ++++++++++++++++++ .../Agent Framework/Agent/WeatherAgent.cs | 22 +++ .../AgentFrameworkWeather.csproj | 4 + samples/dotnet/Agent Framework/Program.cs | 9 ++ 5 files changed, 273 insertions(+) create mode 100644 samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs create mode 100644 samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs new file mode 100644 index 00000000..f6fab5e7 --- /dev/null +++ b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Security.Claims; +using Discord; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Logging; +using IActivity = Microsoft.Agents.Core.Models.IActivity; + +namespace AgentFrameworkWeather.Adapters +{ + /// + /// A custom for Discord. + /// + /// Discord has no built-in Azure Bot Service channel, so this adapter is the translation + /// layer between the Discord gateway and the Agents SDK, exactly like the SDK's A2AAdapter: + /// * INBOUND - turns a Discord message into an Activity + /// and calls , which drives the shared + /// WeatherAgent (AgentApplication) through the SDK turn pipeline. + /// * OUTBOUND - renders the agent's reply Activity as a + /// Discord embed and posts it back to the originating channel. + /// + /// The same WeatherAgent is reused unchanged; only this adapter differs from Slack/Teams. + /// + public class DiscordAdapter(ILogger logger) : ChannelAdapter(logger) + { + /// Discord's channel id used on the Activity. + public const string ChannelId = "discord"; + + private readonly ILogger _logger = logger; + + // Maps an Activity conversation id (the Discord channel id) to the live Discord channel, + // so SendActivitiesAsync knows where to post the reply. + private readonly ConcurrentDictionary _channels = new(); + + /// + /// Registers the Discord channel for a conversation so replies can be routed back to it. + /// Called by the gateway service when an inbound message arrives. + /// + public void RegisterChannel(string conversationId, IMessageChannel channel) + => _channels[conversationId] = channel; + + /// + /// INBOUND: run one agent turn for the given activity. Identical shape to A2AAdapter - + /// build a TurnContext and run the SDK pipeline (which invokes the agent's OnTurnAsync). + /// + public override async Task ProcessActivityAsync( + ClaimsIdentity claimsIdentity, + IActivity activity, + AgentCallbackHandler callback, + CancellationToken cancellationToken) + { + var context = new TurnContext(this, activity, claimsIdentity); + await RunPipelineAsync(context, callback, cancellationToken).ConfigureAwait(false); + return null; + } + + /// + /// OUTBOUND: render each reply message as a Discord embed and post it to the channel. + /// + public override async Task SendActivitiesAsync( + ITurnContext turnContext, + IActivity[] activities, + CancellationToken cancellationToken) + { + var responses = new List(); + + foreach (var activity in activities) + { + // Only render actual messages; skip typing/informative activities. + if (activity.Type != ActivityTypes.Message || string.IsNullOrWhiteSpace(activity.Text)) + { + responses.Add(new ResourceResponse()); + continue; + } + + var conversationId = activity.Conversation?.Id ?? turnContext.Activity.Conversation?.Id; + if (conversationId == null || !_channels.TryGetValue(conversationId, out var channel)) + { + _logger.LogWarning("No Discord channel registered for conversation {ConversationId}", conversationId); + responses.Add(new ResourceResponse()); + continue; + } + + // Discord embed description limit is 4096; trim defensively. + var body = activity.Text; + if (body.Length > 4000) + { + body = body[..4000] + "…"; + } + + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithDescription(body) + .WithFooter("Agent Framework + WorkIQ · Discord ChannelAdapter") + .WithCurrentTimestamp() + .Build(); + + var sent = await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + responses.Add(new ResourceResponse { Id = sent.Id.ToString() }); + } + + return [.. responses]; + } + } +} diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs new file mode 100644 index 00000000..53153aef --- /dev/null +++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Claims; +using Discord; +using Discord.WebSocket; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgentFrameworkWeather.Adapters +{ + /// + /// Drives the from the Discord gateway. + /// + /// Discord is WebSocket/event driven (not HTTP), so instead of an /api/messages endpoint we + /// subscribe to the gateway's MessageReceived event, translate each message into an Activity, + /// and call to run the shared WeatherAgent. + /// + /// Only starts when a Discord bot token is configured ("Discord:BotToken" or DISCORD_BOT_TOKEN). + /// + public class DiscordGatewayService( + IConfiguration configuration, + DiscordAdapter adapter, + IServiceProvider services, + ILogger logger) : BackgroundService + { + private DiscordSocketClient _client; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var token = configuration["Discord:BotToken"] + ?? Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogInformation("Discord bot token not configured - Discord channel disabled."); + return; + } + + _client = new DiscordSocketClient(new DiscordSocketConfig + { + GatewayIntents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.DirectMessages | + GatewayIntents.MessageContent, + LogLevel = LogSeverity.Info + }); + + _client.Log += msg => + { + logger.LogInformation("[Discord] {Message}", msg.ToString()); + return Task.CompletedTask; + }; + + _client.Ready += () => + { + logger.LogInformation("Discord bot ready as {User}", _client.CurrentUser); + return Task.CompletedTask; + }; + + _client.MessageReceived += OnDiscordMessageAsync; + + await _client.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); + await _client.StartAsync().ConfigureAwait(false); + + // Keep the service alive until shutdown. + await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }).ConfigureAwait(false); + } + + private async Task OnDiscordMessageAsync(SocketMessage message) + { + if (message is not SocketUserMessage userMessage) return; + if (message.Author.IsBot) return; + + var text = message.Content?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(text)) return; + + var conversationId = message.Channel.Id.ToString(); + + // Tell the adapter which live Discord channel this conversation maps to (for replies). + adapter.RegisterChannel(conversationId, message.Channel); + + // Translate the Discord message into an Activity the agent understands. + var activity = new Activity + { + Type = ActivityTypes.Message, + Id = message.Id.ToString(), + Text = text, + ChannelId = DiscordAdapter.ChannelId, + ServiceUrl = "discord", + Conversation = new ConversationAccount { Id = conversationId }, + From = new ChannelAccount { Id = message.Author.Id.ToString(), Name = message.Author.Username }, + Recipient = new ChannelAccount { Id = _client.CurrentUser.Id.ToString(), Name = _client.CurrentUser.Username }, + }; + + var identity = new ClaimsIdentity(); + + try + { + using (message.Channel.EnterTypingState()) + using (var scope = services.CreateScope()) + { + var agent = scope.ServiceProvider.GetRequiredService(); + await adapter.ProcessActivityAsync(identity, activity, agent.OnTurnAsync, CancellationToken.None) + .ConfigureAwait(false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing Discord message"); + await message.Channel.SendMessageAsync("Sorry, I hit an error while processing that. 🐾") + .ConfigureAwait(false); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_client != null) + { + await _client.StopAsync().ConfigureAwait(false); + await _client.LogoutAsync().ConfigureAwait(false); + } + await base.StopAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index 1617535c..c8671fed 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -144,6 +144,28 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta return; } + // Discord: collect the full response and send it as ONE message activity; the + // DiscordAdapter renders it as an embed. (Streaming would create many partial messages.) + if (string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase)) + { + var sb = new StringBuilder(); + await foreach (var response in _agent.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) + { + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + sb.Append(response.Text); + } + } + turnState.Conversation.SetValue("conversation.threadInfo", (await _agent.SerializeSessionAsync(thread)).ToString()); + + var answer = sb.ToString(); + if (!string.IsNullOrWhiteSpace(answer)) + { + await turnContext.SendActivityAsync(MessageFactory.Text(answer), cancellationToken).ConfigureAwait(false); + } + return; + } + // Non-Slack channels: stream the response back as it is produced. await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Just a moment please..").ConfigureAwait(false); try diff --git a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj index af1581d7..1b7ef720 100644 --- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj +++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj @@ -19,6 +19,10 @@ + + + diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index b92a1f5e..9478a6db 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using AgentFrameworkWeather; +using AgentFrameworkWeather.Adapters; using AgentFrameworkWeather.Agent; using Azure; using Azure.AI.OpenAI; @@ -46,6 +47,14 @@ // Add the bot (which is transient) builder.AddAgent(); +// ********** Discord channel (custom ChannelAdapter) ********** +// Discord has no Azure Bot Service channel, so we host it ourselves: a DiscordAdapter +// (ChannelAdapter) plus a background service that drives it from the Discord gateway. +// Reuses the SAME WeatherAgent as Slack/Teams. Only starts if Discord:BotToken is set. +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +// ********** END Discord channel ********** + // Register IChatClient with correct types builder.Services.AddSingleton(sp => { From e6b0725f089f01ddf6b77ff095c98a3f31bb169e Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Tue, 28 Jul 2026 16:37:27 -0700 Subject: [PATCH 3/7] Fix Discord ChannelAdapter: skip AutoSignIn (use dev bearer token), offload gateway handler, clean nullable warnings --- .../Agent Framework/Adapters/DiscordAdapter.cs | 2 +- .../Adapters/DiscordGatewayService.cs | 16 +++++++++++++--- .../dotnet/Agent Framework/Agent/WeatherAgent.cs | 13 +++++++++---- samples/dotnet/Agent Framework/Program.cs | 9 +++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs index f6fab5e7..fa32005e 100644 --- a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs +++ b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs @@ -54,7 +54,7 @@ public override async Task ProcessActivityAsync( { var context = new TurnContext(this, activity, claimsIdentity); await RunPipelineAsync(context, callback, cancellationToken).ConfigureAwait(false); - return null; + return null!; } /// diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs index 53153aef..21d5e67d 100644 --- a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs +++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs @@ -27,7 +27,7 @@ public class DiscordGatewayService( IServiceProvider services, ILogger logger) : BackgroundService { - private DiscordSocketClient _client; + private DiscordSocketClient? _client; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -61,7 +61,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return Task.CompletedTask; }; - _client.MessageReceived += OnDiscordMessageAsync; + // Offload processing to a background task so a slow agent turn (several seconds while + // WorkIQ MCP tools load and the model runs) does not block the Discord gateway heartbeat. + // Discord.Net warns "A MessageReceived handler is blocking the gateway task" otherwise. + _client.MessageReceived += msg => + { + _ = Task.Run(() => OnDiscordMessageAsync(msg)); + return Task.CompletedTask; + }; await _client.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); await _client.StartAsync().ConfigureAwait(false); @@ -75,6 +82,9 @@ private async Task OnDiscordMessageAsync(SocketMessage message) if (message is not SocketUserMessage userMessage) return; if (message.Author.IsBot) return; + var client = _client; + if (client is null) return; + var text = message.Content?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(text)) return; @@ -93,7 +103,7 @@ private async Task OnDiscordMessageAsync(SocketMessage message) ServiceUrl = "discord", Conversation = new ConversationAccount { Id = conversationId }, From = new ChannelAccount { Id = message.Author.Id.ToString(), Name = message.Author.Username }, - Recipient = new ChannelAccount { Id = _client.CurrentUser.Id.ToString(), Name = _client.CurrentUser.Username }, + Recipient = new ChannelAccount { Id = client.CurrentUser.Id.ToString(), Name = client.CurrentUser.Username }, }; var identity = new ClaimsIdentity(); diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index c8671fed..1b589215 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -115,13 +115,18 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta // Slack: render the response as native Slack Blocks (a card) instead of the // streamed Bot Framework text. Other channels keep the streaming experience. bool isSlack = string.Equals(turnContext.Activity.ChannelId, "slack", StringComparison.OrdinalIgnoreCase); + bool isDiscord = string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase); var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; // Pick the auth handler for this turn (agentic vs OBO). In dev the BEARER_TOKEN path is used. - string? toolAuthHandlerName = turnContext.Activity.IsAgenticRequest() - ? AgenticAuthHandlerName - : OboAuthHandlerName; + // Discord has no OBO/sign-in channel (no IUserTokenClient), so force the dev bearer-token + // path by using a null handler; otherwise the graph OBO handler tries to sign in and fails. + string? toolAuthHandlerName = isDiscord + ? null + : turnContext.Activity.IsAgenticRequest() + ? AgenticAuthHandlerName + : OboAuthHandlerName; var _agent = await GetClientAgent(turnContext, turnState, toolAuthHandlerName); @@ -146,7 +151,7 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta // Discord: collect the full response and send it as ONE message activity; the // DiscordAdapter renders it as an embed. (Streaming would create many partial messages.) - if (string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase)) + if (isDiscord) { var sb = new StringBuilder(); await foreach (var response in _agent.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index 9478a6db..f546d403 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Agents.Core; using Microsoft.Agents.Hosting.AspNetCore; using Microsoft.Agents.Storage; @@ -47,6 +48,14 @@ // Add the bot (which is transient) builder.AddAgent(); +// AutoSignIn selector: skip the OAuth sign-in flow for Discord. Discord has no Azure Bot +// Service channel (no IUserTokenClient), so the default AutoSignIn would try to sign the +// user in on every message and fail ("Sign in for 'graph' completed without a token"). +// Discord uses the dev BEARER_TOKEN path for WorkIQ instead. Other channels keep AutoSignIn on. +builder.Services.AddSingleton(_ => + (turnContext, cancellationToken) => + Task.FromResult(!string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase))); + // ********** Discord channel (custom ChannelAdapter) ********** // Discord has no Azure Bot Service channel, so we host it ourselves: a DiscordAdapter // (ChannelAdapter) plus a background service that drives it from the Discord gateway. From cc7e58bf7d6bf42f9d9030ec303be5b18ac02be2 Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Tue, 28 Jul 2026 16:41:58 -0700 Subject: [PATCH 4/7] Remove standalone Discord agent (v1); superseded by the DiscordAdapter ChannelAdapter that reuses the shared WeatherAgent --- .../dotnet/discordagent/DiscordAgent.csproj | 34 --- samples/dotnet/discordagent/Program.cs | 206 ------------------ .../dotnet/discordagent/Tools/DateTimeTool.cs | 15 -- .../dotnet/discordagent/Tools/WeatherTool.cs | 76 ------- 4 files changed, 331 deletions(-) delete mode 100644 samples/dotnet/discordagent/DiscordAgent.csproj delete mode 100644 samples/dotnet/discordagent/Program.cs delete mode 100644 samples/dotnet/discordagent/Tools/DateTimeTool.cs delete mode 100644 samples/dotnet/discordagent/Tools/WeatherTool.cs diff --git a/samples/dotnet/discordagent/DiscordAgent.csproj b/samples/dotnet/discordagent/DiscordAgent.csproj deleted file mode 100644 index b7f10a95..00000000 --- a/samples/dotnet/discordagent/DiscordAgent.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - Exe - net8.0 - enable - enable - discord-agent-vinchen - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/dotnet/discordagent/Program.cs b/samples/dotnet/discordagent/Program.cs deleted file mode 100644 index b470013b..00000000 --- a/samples/dotnet/discordagent/Program.cs +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Concurrent; -using System.Text; -using Azure; -using Azure.AI.OpenAI; -using Discord; -using Discord.WebSocket; -using DiscordAgent.Tools; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using ModelContextProtocol.Client; - -// Discord host for the weather (+ WorkIQ, next step) agent. -// -// Discord has no first-party Azure Bot Service channel, so we connect directly to the -// Discord Gateway with Discord.Net and drive the Agent Framework agent ourselves: -// Discord message -> agent.RunStreamingAsync -> reply as a Discord embed (Discord's card). - -var config = new ConfigurationBuilder() - .AddUserSecrets(typeof(Program).Assembly) - .AddEnvironmentVariables() - .Build(); - -// Bot token from user-secret "Discord:BotToken" or env var DISCORD_BOT_TOKEN. -var token = config["Discord:BotToken"] ?? Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); -if (string.IsNullOrWhiteSpace(token)) -{ - Console.Error.WriteLine( - "Missing Discord bot token. Set it with:\n" + - " dotnet user-secrets set \"Discord:BotToken\" \"\"\n" + - "or the DISCORD_BOT_TOKEN environment variable."); - return; -} - -// ---- Build the agent (same stack as the weather sample) ---- -var endpoint = config["AIServices:AzureOpenAI:Endpoint"]; -var apiKey = config["AIServices:AzureOpenAI:ApiKey"]; -var deployment = config["AIServices:AzureOpenAI:DeploymentName"]; -var openWeatherApiKey = config["OpenWeatherApiKey"]; - -if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey) || - string.IsNullOrWhiteSpace(deployment) || string.IsNullOrWhiteSpace(openWeatherApiKey)) -{ - Console.Error.WriteLine( - "Missing AI/weather config. Set user-secrets:\n" + - " AIServices:AzureOpenAI:Endpoint, AIServices:AzureOpenAI:ApiKey, AIServices:AzureOpenAI:DeploymentName, OpenWeatherApiKey"); - return; -} - -const string instructions = """ - You are a friendly feline assistant. You always speak like a cat (use "meow", playful cat puns, and emojis when they fit). - - You can help with two kinds of requests, and you must always pick the right tool: - 1. United States weather -- use your weather tools: - - current-weather tool for current conditions (temperature, low/high, wind, humidity, short description). - - forecast tool for the next 5 days (date, high/low, short description). - - date tool to resolve "today". Location is a city name; resolve 2-letter US state codes to the full US state name. - 2. Anything that is NOT United States weather -- use the WorkIQ tools (Microsoft 365 / Teams: chats, channels, teams, messages). - - Routing rule: US weather questions go to the weather tools; every other question goes to the WorkIQ tools. - Format answers nicely in markdown, keep them easy to read, and always speak like a cat. Use emojis if it fits! - """; - -IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey)) - .GetChatClient(deployment) - .AsIChatClient(); - -var weatherTool = new WeatherTool(openWeatherApiKey); -var tools = new List -{ - AIFunctionFactory.Create(DateTimeTool.GetDate), - AIFunctionFactory.Create(weatherTool.GetCurrentWeatherForLocation), - AIFunctionFactory.Create(weatherTool.GetWeatherForecastForLocation), -}; - -// ---- WorkIQ (Agent 365) MCP tools ---- -// Discord has no OBO sign-in channel, so we use a dev bearer token for the Teams MCP server. -// Token from user-secret "WorkIQ:McpTeamsServerToken" or env BEARER_TOKEN_MCP_TEAMSSERVER -// (refresh with: a365 develop get-token ...). If absent, the agent runs weather-only. -var mcpToken = config["WorkIQ:McpTeamsServerToken"] ?? Environment.GetEnvironmentVariable("BEARER_TOKEN_MCP_TEAMSSERVER"); -if (!string.IsNullOrWhiteSpace(mcpToken)) -{ - try - { - var mcpTransport = new SseClientTransport(new SseClientTransportOptions - { - Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer"), - AdditionalHeaders = new Dictionary { ["Authorization"] = $"Bearer {mcpToken}" }, - TransportMode = HttpTransportMode.AutoDetect, - Name = "mcp_TeamsServer", - }); - var mcpClient = await McpClientFactory.CreateAsync(mcpTransport); - var mcpTools = await mcpClient.ListToolsAsync(); - tools.AddRange(mcpTools); - Console.WriteLine($"WorkIQ MCP tools loaded from mcp_TeamsServer: {mcpTools.Count}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"WorkIQ MCP tools failed to load (continuing weather-only): {ex.Message}"); - } -} -else -{ - Console.WriteLine("No WorkIQ MCP token (BEARER_TOKEN_MCP_TEAMSSERVER) - running weather-only."); -} - -AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions -{ - Name = "Purrfect Weather Agent", - ChatOptions = new ChatOptions - { - Temperature = 0.2f, - Tools = tools, - Instructions = instructions, - AllowMultipleToolCalls = true, - }, -}); - -// One conversation session per Discord channel, so context is preserved within a channel. -var sessions = new ConcurrentDictionary(); - -// ---- Discord wiring ---- -var socketConfig = new DiscordSocketConfig -{ - // MessageContent is a privileged intent - enable it in the Discord Developer Portal - // (Bot -> Privileged Gateway Intents -> Message Content Intent). - GatewayIntents = - GatewayIntents.Guilds | - GatewayIntents.GuildMessages | - GatewayIntents.DirectMessages | - GatewayIntents.MessageContent, - LogLevel = LogSeverity.Info -}; - -var client = new DiscordSocketClient(socketConfig); - -client.Log += msg => -{ - Console.WriteLine(msg.ToString()); - return Task.CompletedTask; -}; - -client.Ready += () => -{ - Console.WriteLine($"Discord bot ready as {client.CurrentUser}"); - return Task.CompletedTask; -}; - -client.MessageReceived += async (SocketMessage message) => -{ - // Only handle real user messages; ignore system messages and other bots (incl. ourselves). - if (message is not SocketUserMessage userMessage) return; - if (message.Author.IsBot) return; - - var userText = message.Content?.Trim() ?? string.Empty; - if (string.IsNullOrEmpty(userText)) return; - - try - { - using (userMessage.Channel.EnterTypingState()) - { - var session = sessions.GetOrAdd(message.Channel.Id, _ => agent.CreateSessionAsync().GetAwaiter().GetResult()); - - // Run the agent and collect the full answer. - var sb = new StringBuilder(); - await foreach (var update in agent.RunStreamingAsync(userText, session)) - { - if (update.Role == ChatRole.Assistant && !string.IsNullOrEmpty(update.Text)) - { - sb.Append(update.Text); - } - } - - var answer = sb.ToString(); - if (string.IsNullOrWhiteSpace(answer)) answer = "_(no response)_"; - if (answer.Length > 4000) answer = answer[..4000] + "…"; - - // Discord embed = Discord's equivalent of a Slack Block Kit card. - var embed = new EmbedBuilder() - .WithColor(new Color(0x2E, 0x9B, 0xF5)) - .WithAuthor("🐾 Purrfect Assistant") - .WithDescription(answer) - .WithFooter("Agent Framework + WorkIQ · Discord adapter") - .WithCurrentTimestamp() - .Build(); - - await userMessage.Channel.SendMessageAsync(embed: embed); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error handling message: {ex}"); - await userMessage.Channel.SendMessageAsync("Sorry, I hit an error while processing that. 🐾"); - } -}; - -await client.LoginAsync(TokenType.Bot, token); -await client.StartAsync(); - -Console.WriteLine("Discord agent host started. Press Ctrl+C to exit."); - -// Keep the process running. -await Task.Delay(Timeout.Infinite); diff --git a/samples/dotnet/discordagent/Tools/DateTimeTool.cs b/samples/dotnet/discordagent/Tools/DateTimeTool.cs deleted file mode 100644 index 91f2d42d..00000000 --- a/samples/dotnet/discordagent/Tools/DateTimeTool.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ComponentModel; - -namespace DiscordAgent.Tools; - -/// -/// Simple date/time tool so the agent can resolve "today" for forecasts. -/// -public static class DateTimeTool -{ - [Description("Gets the current date and time (UTC).")] - public static string GetDate() => DateTimeOffset.UtcNow.ToString("f"); -} diff --git a/samples/dotnet/discordagent/Tools/WeatherTool.cs b/samples/dotnet/discordagent/Tools/WeatherTool.cs deleted file mode 100644 index acc93e9c..00000000 --- a/samples/dotnet/discordagent/Tools/WeatherTool.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ComponentModel; -using OpenWeatherMapSharp; -using OpenWeatherMapSharp.Models; - -namespace DiscordAgent.Tools; - -/// -/// Weather lookup tools for the Discord host. Unlike the Bot Framework sample's -/// WeatherLookupTool, this version has no ITurnContext dependency (Discord has no -/// Bot Framework turn), so it just calls OpenWeatherMap and returns the data. -/// -public class WeatherTool(string openWeatherApiKey) -{ - [Description("Retrieves the current weather for a location; location is a US city name and state is the full US state name.")] - public async Task GetCurrentWeatherForLocation(string location, string state) - { - Console.WriteLine($"[weather] current weather for {location}, {state}"); - - var openWeather = new OpenWeatherMapService(openWeatherApiKey); - var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); - if (openWeatherLocation is { IsSuccess: true }) - { - var locationInfo = openWeatherLocation.Response.FirstOrDefault(); - if (locationInfo == null) - { - throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); - } - - var weather = await openWeather.GetWeatherAsync( - locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); - if (weather.IsSuccess) - { - return weather.Response; - } - } - else - { - System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); - } - - return null; - } - - [Description("Retrieves the 5-day weather forecast for a location; location is a US city name and state is the full US state name.")] - public async Task?> GetWeatherForecastForLocation(string location, string state) - { - Console.WriteLine($"[weather] forecast for {location}, {state}"); - - var openWeather = new OpenWeatherMapService(openWeatherApiKey); - var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); - if (openWeatherLocation is { IsSuccess: true }) - { - var locationInfo = openWeatherLocation.Response.FirstOrDefault(); - if (locationInfo == null) - { - throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); - } - - var weather = await openWeather.GetForecastAsync( - locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); - if (weather.IsSuccess) - { - return weather.Response.Items; - } - } - else - { - System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); - } - - return null; - } -} From 653ae18d5b5cfdb108a3e21de9eac75d41783314 Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Wed, 29 Jul 2026 14:02:06 -0700 Subject: [PATCH 5/7] Cache WorkIQ MCP tools per token and warm up on Discord startup to cut per-turn latency --- .../Adapters/DiscordGatewayService.cs | 33 +++ .../Agent Framework/Agent/WeatherAgent.cs | 230 +++++++++++++----- 2 files changed, 208 insertions(+), 55 deletions(-) diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs index 21d5e67d..b99ac68c 100644 --- a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs +++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs @@ -58,6 +58,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _client.Ready += () => { logger.LogInformation("Discord bot ready as {User}", _client.CurrentUser); + // Pre-load WorkIQ MCP tools in the background so the first real message is fast. + _ = Task.Run(WarmUpToolsAsync); return Task.CompletedTask; }; @@ -77,6 +79,37 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }).ConfigureAwait(false); } + /// + /// Pre-load the WorkIQ MCP tools at startup so the first real Discord message is fast. Sends a + /// synthetic "warmup" Event activity through the adapter pipeline (which builds a full TurnContext) + /// and lets the agent populate its shared tool cache. Best-effort: failures fall back to lazy load. + /// + private async Task WarmUpToolsAsync() + { + try + { + var activity = new Activity + { + Type = ActivityTypes.Event, + Name = "warmup", + ChannelId = DiscordAdapter.ChannelId, + ServiceUrl = "discord", + Conversation = new ConversationAccount { Id = "warmup" }, + From = new ChannelAccount { Id = "warmup" }, + }; + + using var scope = services.CreateScope(); + var agent = scope.ServiceProvider.GetRequiredService(); + await adapter.ProcessActivityAsync(new ClaimsIdentity(), activity, agent.OnTurnAsync, CancellationToken.None) + .ConfigureAwait(false); + logger.LogInformation("Discord: WorkIQ MCP tools warm-up complete."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Discord: WorkIQ MCP tools warm-up failed (tools will load on first message)."); + } + } + private async Task OnDiscordMessageAsync(SocketMessage message) { if (message is not SocketUserMessage userMessage) return; diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index 1b589215..1e0f716b 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -15,6 +15,7 @@ using Microsoft.Agents.Extensions.Slack; using Microsoft.Agents.Extensions.Slack.Api; using Microsoft.Extensions.AI; +using System.Collections.Concurrent; using System.Text; using System.Text.Json; @@ -49,6 +50,13 @@ You are a friendly feline assistant. You always speak like a cat (use "meow", pl // (weather-only) when the service or its configuration is unavailable. private readonly IMcpToolRegistrationService? _toolService = null; + // Cache of WorkIQ MCP tools keyed by access-token hash. Loading the tools is an HTTP + // round-trip to mcp_TeamsServer (~5s); the tool set is stable for the life of a token, + // so we reuse it across turns and only reload when the token changes or nears expiry. + private static readonly ConcurrentDictionary _mcpToolsCache = new(); + + private sealed record CachedMcpTools(IList Tools, DateTimeOffset ExpiresAt); + // Auth handler names for MCP access (configurable via appsettings.json). private readonly string? AgenticAuthHandlerName; private readonly string? OboAuthHandlerName; @@ -72,10 +80,30 @@ public WeatherAgent( // Greet when members are added to the conversation OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + // Background warm-up: an Event activity named "warmup" pre-loads the WorkIQ MCP tools + // into the shared cache (e.g. sent by the Discord host at startup) so the first real + // message is fast. Registered before the generic message handler. + OnActivity(ActivityTypes.Event, OnWarmupAsync); + // Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS OnActivity(ActivityTypes.Message, OnMessageAsync); } + /// + /// Handle the "warmup" Event activity: pre-load WorkIQ MCP tools into the cache and return + /// without replying. Ignores any other Event activity. + /// + private async Task OnWarmupAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + if (!string.Equals(turnContext.Activity.Name, "warmup", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _logger?.LogInformation("Warm-up: pre-loading WorkIQ MCP tools for channel {Channel}.", turnContext.Activity.ChannelId); + await WarmUpWorkIqToolsAsync(turnContext).ConfigureAwait(false); + } + /// /// Check if a bearer token is available in the environment for development/testing. /// @@ -256,27 +284,6 @@ private async Task GetClientAgent(ITurnContext context, ITurnState turn AssertionHelpers.ThrowIfNull(context, nameof(context)); AssertionHelpers.ThrowIfNull(_chatClient!, nameof(_chatClient)); - // Acquire the access token once for this turn - used for WorkIQ MCP tool loading. - string? accessToken = null; - string? agentId = null; - if (!string.IsNullOrEmpty(authHandlerName)) - { - // Production / Teams: exchange an OBO token via the auth handler. - accessToken = await UserAuthorization.GetTurnTokenAsync(context, authHandlerName); - agentId = Utility.ResolveAgentIdentity(context, accessToken); - } - else if (TryGetBearerTokenForDevelopment(out var bearerToken)) - { - // Local dev: use the bearer token from `a365 develop get-token`. - _logger?.LogInformation("Using bearer token from environment for WorkIQ MCP."); - accessToken = bearerToken; - agentId = Utility.ResolveAgentIdentity(context, accessToken!); - } - else - { - _logger?.LogWarning("No auth handler or bearer token - WorkIQ MCP tools will not be loaded (weather-only)."); - } - // Setup the local (weather) tools - these are always available. WeatherLookupTool weatherLookupTool = new(context, _configuration!); var toolList = new List @@ -286,40 +293,8 @@ private async Task GetClientAgent(ITurnContext context, ITurnState turn AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation) }; - // Attach the WorkIQ MCP tools on top of the weather tools when available. - if (_toolService != null && !string.IsNullOrEmpty(agentId)) - { - try - { - await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); - - // For the bearer-token (dev) flow, pass the token as an override and - // use the OBO/agentic handler name if configured. - var handlerForMcp = !string.IsNullOrEmpty(authHandlerName) - ? authHandlerName - : OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty; - var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null; - - var a365Tools = await _toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false); - if (a365Tools != null && a365Tools.Count > 0) - { - toolList.AddRange(a365Tools); - } - } - catch (Exception ex) - { - // If setup fails, keep serving weather instead of crashing. - if (ShouldSkipToolingOnErrors()) - { - _logger?.LogWarning(ex, "Failed to register WorkIQ MCP tools. Continuing weather-only (SKIP_TOOLING_ON_ERRORS=true)."); - } - else - { - _logger?.LogError(ex, "Failed to register WorkIQ MCP tools."); - throw; - } - } - } + // Attach the WorkIQ MCP tools (token-cached) on top of the weather tools. + toolList.AddRange(await GetWorkIqMcpToolsAsync(context, authHandlerName, announceLoading: true).ConfigureAwait(false)); // Setup the tools for the agent: var toolOptions = new ChatOptions @@ -351,6 +326,151 @@ private async Task GetClientAgent(ITurnContext context, ITurnState turn .Build(); } + /// + /// Resolve the WorkIQ MCP tools for this operation, using the token-keyed cache. Returns an + /// empty list when auth or tools are unavailable. When is + /// true, a "Loading tools..." status is streamed on a cache miss (skipped for background warm-up). + /// + private async Task> GetWorkIqMcpToolsAsync(ITurnContext context, string? authHandlerName, bool announceLoading) + { + // Acquire the access token once - used for WorkIQ MCP tool loading. + string? accessToken = null; + string? agentId = null; + if (!string.IsNullOrEmpty(authHandlerName)) + { + // Production / Teams: exchange an OBO token via the auth handler. + accessToken = await UserAuthorization.GetTurnTokenAsync(context, authHandlerName); + agentId = Utility.ResolveAgentIdentity(context, accessToken); + } + else if (TryGetBearerTokenForDevelopment(out var bearerToken)) + { + // Local dev: use the bearer token from `a365 develop get-token`. + _logger?.LogInformation("Using bearer token from environment for WorkIQ MCP."); + accessToken = bearerToken; + agentId = Utility.ResolveAgentIdentity(context, accessToken!); + } + else + { + _logger?.LogWarning("No auth handler or bearer token - WorkIQ MCP tools will not be loaded (weather-only)."); + } + + if (_toolService == null || string.IsNullOrEmpty(agentId)) + { + return Array.Empty(); + } + + try + { + // The tool set is bound to the user's access token; cache it by token so we + // only pay the ~5s MCP load once per token instead of on every turn. + var cacheKey = HashToken(accessToken!); + if (_mcpToolsCache.TryGetValue(cacheKey, out var cachedTools) + && cachedTools.ExpiresAt > DateTimeOffset.UtcNow) + { + _logger?.LogInformation("WorkIQ MCP tools served from cache ({Count} tools).", cachedTools.Tools.Count); + return cachedTools.Tools; + } + + if (announceLoading) + { + await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + } + + // For the bearer-token (dev) flow, pass the token as an override and + // use the OBO/agentic handler name if configured. + var handlerForMcp = !string.IsNullOrEmpty(authHandlerName) + ? authHandlerName + : OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty; + var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null; + + var a365Tools = await _toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false); + if (a365Tools != null && a365Tools.Count > 0) + { + // Expire the cache entry shortly before the token itself expires so we + // never invoke a cached tool with a dead token. + var expiresAt = GetTokenExpiry(accessToken!).AddMinutes(-1); + _mcpToolsCache[cacheKey] = new CachedMcpTools(a365Tools, expiresAt); + PruneExpiredMcpToolsCache(); + _logger?.LogInformation("WorkIQ MCP tools loaded from server ({Count} tools); cached until {Expiry:u}.", a365Tools.Count, expiresAt); + return a365Tools; + } + + return Array.Empty(); + } + catch (Exception ex) + { + // If setup fails, keep serving weather instead of crashing. + if (ShouldSkipToolingOnErrors()) + { + _logger?.LogWarning(ex, "Failed to register WorkIQ MCP tools. Continuing weather-only (SKIP_TOOLING_ON_ERRORS=true)."); + return Array.Empty(); + } + + _logger?.LogError(ex, "Failed to register WorkIQ MCP tools."); + throw; + } + } + + /// + /// Pre-load the WorkIQ MCP tools into the shared cache so the first real message does not pay + /// the ~5s load. Intended for background warm-up (e.g. Discord at startup) using the dev bearer + /// token. No-op if tools are already cached or no token is available; never sends a reply. + /// + private Task WarmUpWorkIqToolsAsync(ITurnContext context) + => GetWorkIqMcpToolsAsync(context, authHandlerName: null, announceLoading: false); + + /// + /// Extract the expiry (exp claim) from a JWT access token. Falls back to a short window + /// if the token cannot be parsed, so a bad token never yields a long-lived cache entry. + /// + private static DateTimeOffset GetTokenExpiry(string token) + { + try + { + var parts = token.Split('.'); + if (parts.Length >= 2) + { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("exp", out var expEl) && expEl.TryGetInt64(out var exp)) + { + return DateTimeOffset.FromUnixTimeSeconds(exp); + } + } + } + catch + { + // Ignore parse failures and use the fallback below. + } + return DateTimeOffset.UtcNow.AddMinutes(5); + } + + /// Stable, non-reversible cache key for an access token. + private static string HashToken(string token) + { + var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hash); + } + + /// Drop expired MCP tool cache entries to bound memory as tokens rotate. + private static void PruneExpiredMcpToolsCache() + { + var now = DateTimeOffset.UtcNow; + foreach (var entry in _mcpToolsCache) + { + if (entry.Value.ExpiresAt <= now) + { + _mcpToolsCache.TryRemove(entry.Key, out _); + } + } + } + /// /// Manage Agent threads against the conversation state. /// From 0f2c1fcf91c90594b1a8df40acdff52247fc3aed Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Wed, 29 Jul 2026 14:19:29 -0700 Subject: [PATCH 6/7] Use channel id constants instead of hardcoded strings (review feedback) --- samples/dotnet/Agent Framework/Agent/WeatherAgent.cs | 5 +++-- samples/dotnet/Agent Framework/Program.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index 1e0f716b..831aae78 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using AgentFrameworkWeather.Adapters; using AgentFrameworkWeather.Tools; using Microsoft.Agents.A365.Runtime.Utils; using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; @@ -142,8 +143,8 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta { // Slack: render the response as native Slack Blocks (a card) instead of the // streamed Bot Framework text. Other channels keep the streaming experience. - bool isSlack = string.Equals(turnContext.Activity.ChannelId, "slack", StringComparison.OrdinalIgnoreCase); - bool isDiscord = string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase); + bool isSlack = turnContext.Activity.ChannelId == Channels.Slack; + bool isDiscord = turnContext.Activity.ChannelId == DiscordAdapter.ChannelId; var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index f546d403..30088015 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -54,7 +54,7 @@ // Discord uses the dev BEARER_TOKEN path for WorkIQ instead. Other channels keep AutoSignIn on. builder.Services.AddSingleton(_ => (turnContext, cancellationToken) => - Task.FromResult(!string.Equals(turnContext.Activity.ChannelId, "discord", StringComparison.OrdinalIgnoreCase))); + Task.FromResult(turnContext.Activity.ChannelId != DiscordAdapter.ChannelId)); // ********** Discord channel (custom ChannelAdapter) ********** // Discord has no Azure Bot Service channel, so we host it ourselves: a DiscordAdapter From e1aada3c936a360aca6f7c5e1d91b0e80c80a290 Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Tue, 4 Aug 2026 16:06:37 -0700 Subject: [PATCH 7/7] Add OAuth sign-in support to the Discord channel adapter Discord isn't an Azure Bot channel, so DiscordAdapter now provides its own IUserTokenClient (via IChannelServiceClientFactory) and overrides ProcessProactiveAsync to re-run the banked activity after sign-in completes. Renders the OAuth sign-in card as a Discord embed with a real sign-in link, and builds a bot ClaimsIdentity so the token client can authenticate. Also wires up OBO auth handlers, ForceLogout, and bumps A365 tooling to 1.1.14-preview. --- .../Adapters/DiscordAdapter.cs | 140 +++++++++++++++++- .../Adapters/DiscordGatewayService.cs | 16 +- .../Agent Framework/Agent/WeatherAgent.cs | 18 ++- .../AgentFrameworkWeather.csproj | 10 +- samples/dotnet/Agent Framework/Program.cs | 10 +- .../dotnet/Agent Framework/appsettings.json | 20 ++- 6 files changed, 184 insertions(+), 30 deletions(-) diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs index fa32005e..9470e1c9 100644 --- a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs +++ b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs @@ -3,9 +3,12 @@ using System.Collections.Concurrent; using System.Security.Claims; +using System.Text.Json; using Discord; using Microsoft.Agents.Builder; +using Microsoft.Agents.Connector; using Microsoft.Agents.Core.Models; +using Microsoft.Agents.Core.Serialization; using Microsoft.Extensions.Logging; using IActivity = Microsoft.Agents.Core.Models.IActivity; @@ -24,12 +27,13 @@ namespace AgentFrameworkWeather.Adapters /// /// The same WeatherAgent is reused unchanged; only this adapter differs from Slack/Teams. /// - public class DiscordAdapter(ILogger logger) : ChannelAdapter(logger) + public class DiscordAdapter(ILogger logger, IChannelServiceClientFactory channelServiceClientFactory) : ChannelAdapter(logger) { /// Discord's channel id used on the Activity. public const string ChannelId = "discord"; private readonly ILogger _logger = logger; + private readonly IChannelServiceClientFactory _channelServiceClientFactory = channelServiceClientFactory; // Maps an Activity conversation id (the Discord channel id) to the live Discord channel, // so SendActivitiesAsync knows where to post the reply. @@ -51,14 +55,48 @@ public override async Task ProcessActivityAsync( IActivity activity, AgentCallbackHandler callback, CancellationToken cancellationToken) + { + await RunPipelineWithUserTokenAsync(claimsIdentity, activity, callback, cancellationToken).ConfigureAwait(false); + return null!; + } + + /// + /// PROACTIVE: after a sign-in completes, the SDK re-runs the original ("banked") activity via + /// this method. It must set up the same IUserTokenClient as ProcessActivityAsync, otherwise the + /// resumed turn (which reads the user token) fails with "IUserTokenClient is not available". + /// + public override Task ProcessProactiveAsync( + ClaimsIdentity claimsIdentity, + IActivity continuationActivity, + string audience, + AgentCallbackHandler callback, + CancellationToken cancellationToken) + => RunPipelineWithUserTokenAsync(claimsIdentity, continuationActivity, callback, cancellationToken); + + /// + /// Build a TurnContext, attach an IUserTokenClient (Discord isn't an Azure Bot channel, so the + /// base ChannelAdapter doesn't create one), and run the SDK turn pipeline. Used by both the + /// inbound and proactive (post-sign-in re-run) paths so the OAuth/OBO flow works over Discord. + /// + private async Task RunPipelineWithUserTokenAsync( + ClaimsIdentity claimsIdentity, + IActivity activity, + AgentCallbackHandler callback, + CancellationToken cancellationToken) { var context = new TurnContext(this, activity, claimsIdentity); + + using var userTokenClient = await _channelServiceClientFactory + .CreateUserTokenClientAsync(claimsIdentity, useAnonymous: null, cancellationToken).ConfigureAwait(false); + context.Services.Set(userTokenClient); + context.Services.Set(_channelServiceClientFactory); + await RunPipelineAsync(context, callback, cancellationToken).ConfigureAwait(false); - return null!; } /// /// OUTBOUND: render each reply message as a Discord embed and post it to the channel. + /// Sign-in (OAuthCard) activities are rendered as a clickable sign-in link. /// public override async Task SendActivitiesAsync( ITurnContext turnContext, @@ -69,6 +107,14 @@ public override async Task SendActivitiesAsync( foreach (var activity in activities) { + // OAuth sign-in: Discord isn't a known Bot Service channel, so the OAuthCard carries + // no usable link. Fetch the real sign-in URL and post it as a Discord message. + if (TryGetOAuthConnectionName(activity, out var connectionName)) + { + responses.Add(await SendSignInAsync(turnContext, connectionName, cancellationToken).ConfigureAwait(false)); + continue; + } + // Only render actual messages; skip typing/informative activities. if (activity.Type != ActivityTypes.Message || string.IsNullOrWhiteSpace(activity.Text)) { @@ -76,10 +122,8 @@ public override async Task SendActivitiesAsync( continue; } - var conversationId = activity.Conversation?.Id ?? turnContext.Activity.Conversation?.Id; - if (conversationId == null || !_channels.TryGetValue(conversationId, out var channel)) + if (!TryGetChannel(activity, turnContext, out var channel)) { - _logger.LogWarning("No Discord channel registered for conversation {ConversationId}", conversationId); responses.Add(new ResourceResponse()); continue; } @@ -105,5 +149,91 @@ public override async Task SendActivitiesAsync( return [.. responses]; } + + /// Resolve the live Discord channel for the conversation the activity belongs to. + private bool TryGetChannel(IActivity activity, ITurnContext turnContext, out IMessageChannel channel) + { + var conversationId = activity.Conversation?.Id ?? turnContext.Activity.Conversation?.Id; + if (conversationId == null || !_channels.TryGetValue(conversationId, out channel!)) + { + _logger.LogWarning("No Discord channel registered for conversation {ConversationId}", conversationId); + channel = null!; + return false; + } + return true; + } + + /// + /// Post the OAuth sign-in link to Discord. The IUserTokenClient (set on the turn) resolves the + /// real sign-in URL from the Bot Framework Token Service; the user clicks it, signs in, and + /// pastes the returned code back into the chat to complete the flow. + /// + private async Task SendSignInAsync(ITurnContext turnContext, string connectionName, CancellationToken cancellationToken) + { + if (!TryGetChannel(turnContext.Activity, turnContext, out var channel)) + { + return new ResourceResponse(); + } + + var userTokenClient = turnContext.Services.Get(); + var signInResource = await userTokenClient + .GetSignInResourceAsync(connectionName, turnContext.Activity, null, cancellationToken).ConfigureAwait(false); + var link = signInResource?.SignInLink; + if (string.IsNullOrEmpty(link)) + { + _logger.LogWarning("[Discord] No sign-in link available for connection {Connection}", connectionName); + return new ResourceResponse(); + } + + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithTitle("Sign in required") + .WithDescription($"Please [sign in here]({link}) to continue, then paste the code you receive back into this chat.") + .WithFooter("Agent Framework + WorkIQ · Discord ChannelAdapter") + .WithCurrentTimestamp() + .Build(); + + var sent = await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + return new ResourceResponse { Id = sent.Id.ToString() }; + } + + /// Detect an OAuthCard attachment and return its OAuth connection name. + private static bool TryGetOAuthConnectionName(IActivity activity, out string connectionName) + { + connectionName = null!; + if (activity.Attachments is null) + { + return false; + } + + foreach (var attachment in activity.Attachments) + { + if (!string.Equals(attachment.ContentType, "application/vnd.microsoft.card.oauth", StringComparison.OrdinalIgnoreCase) + || attachment.Content is null) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(ProtocolJsonSerializer.ToJson(attachment.Content)); + if (doc.RootElement.TryGetProperty("connectionName", out var cn)) + { + connectionName = cn.GetString()!; + if (!string.IsNullOrEmpty(connectionName)) + { + return true; + } + } + } + catch (JsonException) + { + // Not a parseable OAuthCard; ignore. + } + } + + return false; + } } } diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs index b99ac68c..afaf1788 100644 --- a/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs +++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using Discord; using Discord.WebSocket; +using Microsoft.Agents.Authentication; using Microsoft.Agents.Builder; using Microsoft.Agents.Core.Models; using Microsoft.Extensions.DependencyInjection; @@ -100,7 +101,7 @@ private async Task WarmUpToolsAsync() using var scope = services.CreateScope(); var agent = scope.ServiceProvider.GetRequiredService(); - await adapter.ProcessActivityAsync(new ClaimsIdentity(), activity, agent.OnTurnAsync, CancellationToken.None) + await adapter.ProcessActivityAsync(CreateBotClaimsIdentity(), activity, agent.OnTurnAsync, CancellationToken.None) .ConfigureAwait(false); logger.LogInformation("Discord: WorkIQ MCP tools warm-up complete."); } @@ -139,7 +140,7 @@ private async Task OnDiscordMessageAsync(SocketMessage message) Recipient = new ChannelAccount { Id = client.CurrentUser.Id.ToString(), Name = client.CurrentUser.Username }, }; - var identity = new ClaimsIdentity(); + var identity = CreateBotClaimsIdentity(); try { @@ -159,6 +160,17 @@ await adapter.ProcessActivityAsync(identity, activity, agent.OnTurnAsync, Cancel } } + // Build a non-anonymous ClaimsIdentity carrying the Azure Bot's app id so the adapter can + // create an IUserTokenClient (the OAuth sign-in flow needs the bot app id to call the + // Bot Framework Token Service). Falls back to an empty identity if not configured. + private ClaimsIdentity CreateBotClaimsIdentity() + { + var botAppId = configuration["Connections:BotServiceConnection:Settings:ClientId"]; + return string.IsNullOrEmpty(botAppId) + ? new ClaimsIdentity() + : AgentClaims.CreateIdentity(botAppId, appId: botAppId); + } + public override async Task StopAsync(CancellationToken cancellationToken) { if (_client != null) diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index 831aae78..08d6b68f 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -76,7 +76,7 @@ public WeatherAgent( // Read auth handler names from configuration (can be empty/null to disable). AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); - OboAuthHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName"); + OboAuthHandlerName = _configuration.GetValue("AgentApplication:UserAuthorization:Handlers:mcs:Settings:AzureBotOAuthConnectionName"); // Greet when members are added to the conversation OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); @@ -86,8 +86,15 @@ public WeatherAgent( // message is fast. Registered before the generic message handler. OnActivity(ActivityTypes.Event, OnWarmupAsync); + OnMessage("ForceLogout", OnLogout); + // Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS - OnActivity(ActivityTypes.Message, OnMessageAsync); + OnActivity(ActivityTypes.Message, OnMessageAsync, autoSignInHandlers: [OboAuthHandlerName]); + } + + private async Task OnLogout(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await UserAuthorization.SignOutUserAsync(turnContext, turnState); } /// @@ -146,11 +153,14 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta bool isSlack = turnContext.Activity.ChannelId == Channels.Slack; bool isDiscord = turnContext.Activity.ChannelId == DiscordAdapter.ChannelId; + var userToken = await UserAuthorization.GetTurnTokenAsync(turnContext, OboAuthHandlerName); + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; // Pick the auth handler for this turn (agentic vs OBO). In dev the BEARER_TOKEN path is used. - // Discord has no OBO/sign-in channel (no IUserTokenClient), so force the dev bearer-token - // path by using a null handler; otherwise the graph OBO handler tries to sign in and fails. + // Discord signs the user in (custom DiscordAdapter provides the IUserTokenClient), but the + // WorkIQ tools still use the dev bearer-token path here; wiring the tools to the user's OBO + // token is a follow-up. string? toolAuthHandlerName = isDiscord ? null : turnContext.Activity.IsAgenticRequest() diff --git a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj index 1b7ef720..7f263530 100644 --- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj +++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj @@ -24,20 +24,18 @@ - - + + - + - + diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index 30088015..2d14e576 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -11,6 +11,7 @@ using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; using Microsoft.Agents.Hosting.AspNetCore; using Microsoft.Agents.Storage; using Microsoft.Agents.Storage.Transcript; @@ -48,13 +49,12 @@ // Add the bot (which is transient) builder.AddAgent(); -// AutoSignIn selector: skip the OAuth sign-in flow for Discord. Discord has no Azure Bot -// Service channel (no IUserTokenClient), so the default AutoSignIn would try to sign the -// user in on every message and fail ("Sign in for 'graph' completed without a token"). -// Discord uses the dev BEARER_TOKEN path for WorkIQ instead. Other channels keep AutoSignIn on. +// AutoSignIn selector: run the OAuth sign-in flow only for user Message activities. This lets +// Slack AND Discord sign the user in on their first message, while skipping non-message turns +// such as the Discord startup "warmup" Event (which loads tools and must not prompt sign-in). builder.Services.AddSingleton(_ => (turnContext, cancellationToken) => - Task.FromResult(turnContext.Activity.ChannelId != DiscordAdapter.ChannelId)); + Task.FromResult(turnContext.Activity.Type == ActivityTypes.Message)); // ********** Discord channel (custom ChannelAdapter) ********** // Discord has no Azure Bot Service channel, so we host it ourselves: a DiscordAdapter diff --git a/samples/dotnet/Agent Framework/appsettings.json b/samples/dotnet/Agent Framework/appsettings.json index 5cc6ac86..a303ff6f 100644 --- a/samples/dotnet/Agent Framework/appsettings.json +++ b/samples/dotnet/Agent Framework/appsettings.json @@ -7,17 +7,21 @@ "TenantId": "{{TenantId}}" }, - "AgentApplicationOptions": { +"AgentApplication": { "StartTypingTimer": true, "RemoveRecipientMention": false, - "NormalizeMentions": false - }, + "NormalizeMentions": false, - "AgentApplication": { - // WorkIQ MCP auth handler names. Leave empty for local dev (uses BEARER_TOKEN env var). - // Set these for production / Teams to use OBO / agentic token exchange. - "AgenticAuthHandlerName": "", - "OboAuthHandlerName": "" + "UserAuthorization": { + "DefaultHandlerName": "graph", + "Handlers": { + "mcs": { + "Settings": { + "AzureBotOAuthConnectionName": "graph" + } + } + } + } }, "Logging": {