diff --git a/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs
new file mode 100644
index 00000000..9470e1c9
--- /dev/null
+++ b/samples/dotnet/Agent Framework/Adapters/DiscordAdapter.cs
@@ -0,0 +1,239 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+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;
+
+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, 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.
+ 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)
+ {
+ 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);
+ }
+
+ ///
+ /// 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,
+ IActivity[] activities,
+ CancellationToken cancellationToken)
+ {
+ var responses = new List();
+
+ 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))
+ {
+ responses.Add(new ResourceResponse());
+ continue;
+ }
+
+ if (!TryGetChannel(activity, turnContext, out var channel))
+ {
+ 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];
+ }
+
+ /// 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
new file mode 100644
index 00000000..afaf1788
--- /dev/null
+++ b/samples/dotnet/Agent Framework/Adapters/DiscordGatewayService.cs
@@ -0,0 +1,184 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+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;
+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);
+ // Pre-load WorkIQ MCP tools in the background so the first real message is fast.
+ _ = Task.Run(WarmUpToolsAsync);
+ return Task.CompletedTask;
+ };
+
+ // 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);
+
+ // Keep the service alive until shutdown.
+ 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(CreateBotClaimsIdentity(), 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;
+ 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;
+
+ 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 = CreateBotClaimsIdentity();
+
+ 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);
+ }
+ }
+
+ // 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)
+ {
+ 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 6cfaf357..08d6b68f 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;
@@ -75,13 +76,40 @@ 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);
+ // 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);
+
+ 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);
+ }
+
+ ///
+ /// 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);
}
///
@@ -123,13 +151,21 @@ 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 = 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.
- string? toolAuthHandlerName = turnContext.Activity.IsAgenticRequest()
- ? AgenticAuthHandlerName
- : OboAuthHandlerName;
+ // 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()
+ ? AgenticAuthHandlerName
+ : OboAuthHandlerName;
var _agent = await GetClientAgent(turnContext, turnState, toolAuthHandlerName);
@@ -152,6 +188,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 (isDiscord)
+ {
+ 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
@@ -364,6 +422,14 @@ private async Task> GetWorkIqMcpToolsAsync(ITurnContext context, s
}
}
+ ///
+ /// 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.
diff --git a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj
index af1581d7..7f263530 100644
--- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj
+++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj
@@ -19,21 +19,23 @@
+
+
+
-
-
+
+
-
+
-
+
diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs
index b92a1f5e..2d14e576 100644
--- a/samples/dotnet/Agent Framework/Program.cs
+++ b/samples/dotnet/Agent Framework/Program.cs
@@ -2,13 +2,16 @@
// Licensed under the MIT License.
using AgentFrameworkWeather;
+using AgentFrameworkWeather.Adapters;
using AgentFrameworkWeather.Agent;
using Azure;
using Azure.AI.OpenAI;
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.Core.Models;
using Microsoft.Agents.Hosting.AspNetCore;
using Microsoft.Agents.Storage;
using Microsoft.Agents.Storage.Transcript;
@@ -46,6 +49,21 @@
// Add the bot (which is transient)
builder.AddAgent();
+// 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.Type == ActivityTypes.Message));
+
+// ********** 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 => {
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": {