Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ a365.generated.config.json
app.zip
publish/

# Sensitive / per-developer config
**/Properties/launchSettings.json
**/appsettings.Development.json
*.csproj.user

# OS-specific files
.DS_Store
Thumbs.db
9 changes: 9 additions & 0 deletions dotnet/work-iq-teams-bot/nuget.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<add key="TeamsSDKPreviews" value="https://pkgs.dev.azure.com/DomoreexpGithub/Github_Pipelines/_packaging/TeamsSDKPreviews/nuget/v3/index.json" />
</packageSources>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

var builder = DistributedApplication.CreateBuilder(args);

builder.AddProject<Projects.work_iq_teams_bot_TeamsApp>("teamsbotapp", "Teams365Demo")
.WithHttpHealthCheck("/health");

builder.Build().Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.2">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>7163dbbd-0055-4d5d-a0c0-1851534e6203</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\work-iq-teams-bot.TeamsApp\work-iq-teams-bot.TeamsApp.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;
using Microsoft.OpenTelemetry;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace Microsoft.Extensions.Hosting;

// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";

public static TBuilder AddServiceDefaults<TBuilder>(
this TBuilder builder,
string[]? activitySources = null,
string[]? meterNames = null,
Func<IServiceProvider>? rootProviderAccessor = null) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry(activitySources, meterNames, rootProviderAccessor);

builder.AddDefaultHealthChecks();

builder.Services.AddServiceDiscovery();

builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();

// Turn on service discovery by default
http.AddServiceDiscovery();
});

return builder;
}

public static TBuilder ConfigureOpenTelemetry<TBuilder>(
this TBuilder builder,
string[]? activitySources = null,
string[]? meterNames = null,
Func<IServiceProvider>? rootProviderAccessor = null) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});

builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService(
serviceName: builder.Environment.ApplicationName,
serviceVersion: "0.0.1")
.AddAttributes(new Dictionary<string, object>
{
["service.namespace"] = "TeamsSamples"
}))
.UseMicrosoftOpenTelemetry(o =>
{
o.Exporters = ExportTarget.Otlp | ExportTarget.AzureMonitor | ExportTarget.Agent365;
o.Instrumentation.EnableHttpClientInstrumentation = true;
o.Instrumentation.EnableAspNetCoreInstrumentation = true;
o.Agent365.Exporter.UseS2SEndpoint = true;
if (rootProviderAccessor is not null)
{
o.Agent365.Exporter.TokenResolver = async (agentId, tenantId) =>
{
var provider = rootProviderAccessor().GetRequiredService<IAuthorizationHeaderProvider>();
var options = new AuthorizationHeaderProviderOptions { AcquireTokenOptions = new() { AuthenticationOptionsName = "AzureAd", Tenant = tenantId } };
options.WithAgentIdentity(agentId);
var token = await provider.CreateAuthorizationHeaderForAppAsync(
"api://9b975845-388f-4429-889e-eab1ef63949c/.default", options);
return token.Substring("Bearer".Length).Trim();
};
}
})
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();

if (meterNames is { Length: > 0 })
{
metrics.AddMeter(meterNames);
}
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(options =>
// Exclude health check requests from tracing
options.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
.AddHttpClientInstrumentation(options =>
{
// Suppress known-noisy spans from Agent365 MCP service endpoints.
// GET (SSE listener) returns 405 and DELETE (session teardown) returns 500
// due to server-side issues. See docs/mcp-service-http-errors.md.
options.FilterHttpRequestMessage = request =>
{
if (request.RequestUri?.Host is "agent365.svc.cloud.microsoft"
&& request.RequestUri.AbsolutePath.StartsWith("/agents/servers/", StringComparison.OrdinalIgnoreCase))
{
return request.Method != HttpMethod.Get
&& request.Method != HttpMethod.Delete;
}

return true;
};
});

if (activitySources is { Length: > 0 })
{
tracing.AddSource(activitySources);
}
});

return builder;
}

public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);

return builder;
}

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);

// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}

return app;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
<PackageReference Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.5.0" />
<PackageReference Include="Microsoft.Identity.Abstractions" Version="12.0.0" />
<PackageReference Include="Microsoft.Identity.Web.AgentIdentities" Version="4.8.0" />
<PackageReference Include="Microsoft.OpenTelemetry" Version="1.0.2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.AI;
using System.Collections.Concurrent;

namespace work_iq_teams_bot.TeamsApp;

/// <summary>
/// Stores per-conversation chat history and provides a serialization gate so
/// concurrent turns within the same conversation cannot interleave history mutations.
/// Implementations are expected to be registered as a singleton, since per-conversation
/// state must outlive any individual turn.
/// </summary>
internal interface IConversationHistoryStore
{
/// <summary>
/// Returns the conversation's chat history, creating it from <paramref name="seed"/> on first access.
/// </summary>
/// <param name="conversationId">The conversation identifier.</param>
/// <param name="seed">Initial messages (typically a system prompt) used only on first access.</param>
List<ChatMessage> GetOrCreateHistory(string conversationId, Func<IEnumerable<ChatMessage>> seed);

/// <summary>
/// Acquires the per-conversation serialization gate. Dispose the returned handle to release.
/// </summary>
Task<IAsyncDisposable> AcquireGateAsync(string conversationId, CancellationToken cancellationToken);
}

/// <summary>
/// Process-local <see cref="IConversationHistoryStore"/> backed by <see cref="ConcurrentDictionary{TKey, TValue}"/>.
/// History grows unbounded for the lifetime of the process; replace with a distributed/bounded store for production.
/// </summary>
internal sealed class InMemoryConversationHistoryStore : IConversationHistoryStore
{
private readonly ConcurrentDictionary<string, List<ChatMessage>> _histories = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

public List<ChatMessage> GetOrCreateHistory(string conversationId, Func<IEnumerable<ChatMessage>> seed)
{
ArgumentException.ThrowIfNullOrEmpty(conversationId);
ArgumentNullException.ThrowIfNull(seed);

return _histories.GetOrAdd(conversationId, _ => [.. seed()]);
}

public async Task<IAsyncDisposable> AcquireGateAsync(string conversationId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(conversationId);

SemaphoreSlim gate = _locks.GetOrAdd(conversationId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
return new GateHandle(gate);
}

private sealed class GateHandle(SemaphoreSlim gate) : IAsyncDisposable
{
private int _released;

public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _released, 1) == 0)
{
gate.Release();
}
return ValueTask.CompletedTask;
}
}
}
26 changes: 26 additions & 0 deletions dotnet/work-iq-teams-bot/work-iq-teams-bot.TeamsApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Teams.Apps;
using Microsoft.Teams.Apps.Diagnostics;
using Microsoft.Teams.Core.Diagnostics;
using work_iq_teams_bot.TeamsApp;

WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
IServiceProvider? rootServiceProvider = null;

builder.AddServiceDefaults(
activitySources: [CoreTelemetryNames.ActivitySourceName, TeamsBotApplicationTelemetry.ActivitySourceName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"],
meterNames: [CoreTelemetryNames.MeterName, TeamsBotApplicationTelemetry.MeterName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"],
rootProviderAccessor: () => rootServiceProvider!);

builder.Services
.AddWorkIQAgent(builder.Configuration)
.AddTeamsBotApplication<WorkIQTeamsBotApp>();

WebApplication webApp = builder.Build();
rootServiceProvider = webApp.Services;
webApp.MapDefaultEndpoints();
webApp.UseTeamsBotApplication<WorkIQTeamsBotApp>();
webApp.Run();

Loading
Loading