diff --git a/Microsoft.Teams.slnx b/Microsoft.Teams.slnx index f65f8d4d..e069d9a8 100644 --- a/Microsoft.Teams.slnx +++ b/Microsoft.Teams.slnx @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index aa175d7a..31acae33 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ dotnet samples/scenarios/middleware.cs -- --urls "http://localhost:3978" | Sample | Description | |--------|-------------| | [A2ABot](samples/A2ABot/) | Agent-to-agent handoff bot | +| [AIFileAnalysisBot](samples/AIFileAnalysisBot/) | Analyzes attached files with Azure OpenAI | | [ExtAIBot](samples/ExtAIBot/) | `Microsoft.Extensions.AI` integration | | [McpServer](samples/McpServer/) | MCP server with Teams and Graph tools | | [StreamingBot](samples/StreamingBot/) | Progressive streaming responses | diff --git a/samples/AIFileAnalysisBot/AIFileAnalysisBot.csproj b/samples/AIFileAnalysisBot/AIFileAnalysisBot.csproj new file mode 100644 index 00000000..1e417854 --- /dev/null +++ b/samples/AIFileAnalysisBot/AIFileAnalysisBot.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/samples/AIFileAnalysisBot/AnalysisRunner.cs b/samples/AIFileAnalysisBot/AnalysisRunner.cs new file mode 100644 index 00000000..75a4349e --- /dev/null +++ b/samples/AIFileAnalysisBot/AnalysisRunner.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Apps; + +namespace AIFileAnalysisBot; + +internal sealed class AnalysisRunner(IChatClient chatClient, ILogger logger) +{ + private const string SystemPrompt = """ + You analyze files supplied by the user. + + Base your answer on the user's message and the attached content. State clearly when the available files do not support + a conclusion. Do not claim to have inspected files that were not included. Keep the response concise and practical. + """; + + /// + /// Sends one stateless request for the current message and streams the reply. + /// + /// SAMPLE GUARDRAIL: nothing is carried between turns. A stateful agent would keep history here, but that would let + /// a later message silently reuse file content the user did not attach to it, and would resend every image on every + /// following turn. + /// + public async Task RunAsync(AnalysisRequest request, TeamsStreamingWriter writer, CancellationToken cancellationToken) + { + try + { + await writer.SendInformativeUpdateAsync("Analyzing files...", cancellationToken); + + ChatMessage[] messages = + [ + new ChatMessage(ChatRole.System, SystemPrompt), + new ChatMessage(ChatRole.User, request.Content), + ]; + + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + messages, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + await writer.AppendResponseAsync(update.Text, cancellationToken); + } + } + + await writer.FinalizeResponseAsync(new MessageActivityInput().AddAIGenerated(), cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "File analysis failed"); + + bool rateLimited = (ex as ClientResultException)?.Status == 429 + || ex.Message.StartsWith("429 ", StringComparison.Ordinal); + MessageActivityInput failure = new MessageActivityInput() + .WithText(rateLimited + ? "The AI service is temporarily rate-limited. Please wait a moment and try again." + : "I could not analyze those files. Please try again.") + .AddAIGenerated(); + + await writer.FinalizeResponseAsync(failure, cancellationToken); + } + } +} diff --git a/samples/AIFileAnalysisBot/FileCard.cs b/samples/AIFileAnalysisBot/FileCard.cs new file mode 100644 index 00000000..20608926 --- /dev/null +++ b/samples/AIFileAnalysisBot/FileCard.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Apps.Files; +using Microsoft.Teams.Cards; + +namespace AIFileAnalysisBot; + +internal static class FileCard +{ + private const string UnsupportedNote = + "I downloaded this file but did not analyze it. This sample sends only text files and PNG, JPEG, GIF, " + + "or WebP images to the model."; + + /// + /// FILE RECEIVE: the no-LLM response for a file this sample will not send to the model. + /// + /// Nothing here touches Azure OpenAI. It reports what the file API exposes (scope, source, resolved content type) + /// plus the byte count that was actually downloaded, so the file round-trip is still demonstrated for formats the + /// model never sees. + /// + /// The incoming file, used for the scope and source facts. + /// The downloaded copy, used for filename, content type, and byte count. + /// + /// Overrides the closing explanation. Defaults to the unsupported-format wording; the no-model path passes its + /// own so the card does not imply the file type was the problem. + /// + public static JsonElement Unsupported(IncomingFile file, DownloadedFile downloaded, string? note = null) + { + AdaptiveCard card = new([ + new Container( + new TextBlock("File received") + { + Weight = TextWeight.Bolder, + Size = TextSize.Large, + Color = TextColor.Accent, + }, + new TextBlock(downloaded.Filename) + { + Weight = TextWeight.Bolder, + Wrap = true, + }) + { + Style = ContainerStyle.Emphasis, + }, + new FactSet( + new Fact("Type", downloaded.ContentType), + new Fact("Size", HumanSize(downloaded.Bytes.Length)), + new Fact("Scope", file.Scope.ToString()), + new Fact("Source", file.Source.ToString())), + new TextBlock(note ?? UnsupportedNote) + { + Wrap = true, + IsSubtle = true, + Spacing = Spacing.Medium, + }]) + { + Version = Microsoft.Teams.Cards.Version.Version1_5, + }; + + return JsonSerializer.SerializeToElement(card); + } + + private static string HumanSize(int bytes) + { + if (bytes < 1024) + { + return $"{bytes} B"; + } + + string[] units = ["KB", "MB", "GB"]; + double value = bytes / 1024.0; + int unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + + return $"{value:F1} {units[unit]}"; + } +} diff --git a/samples/AIFileAnalysisBot/FileContext.cs b/samples/AIFileAnalysisBot/FileContext.cs new file mode 100644 index 00000000..b19aa1c4 --- /dev/null +++ b/samples/AIFileAnalysisBot/FileContext.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Apps.Files; + +namespace AIFileAnalysisBot; + +/// +/// Whether this sample can send a downloaded file to the model, and as what. +/// +internal enum FileKind +{ + Image, + Text, + Unsupported, +} + +/// +/// A downloaded file that accepted, paired with its kind. +/// +internal sealed record AnalyzableFile(DownloadedFile File, FileKind Kind); + +/// +/// A model request built from the user's message and their analyzable files. +/// +/// The content parts sent as the user message. +/// User-facing explanations for files that were skipped or truncated. +/// Number of files whose content reached the model request. +internal sealed record AnalysisRequest(IList Content, IList Warnings, int FileCount); + +internal static class FileContext +{ + // SAMPLE GUARDRAIL: every constant below is a product choice made by this sample, not a Teams SDK or Azure OpenAI + // limit. They exist to keep one Teams message from turning into an unbounded model request. Pick your own values. + // + // DownloadAsync buffers the whole file before any of these are checked, so they bound what reaches the model, not + // network transfer or process memory. + private const int MaxFiles = 5; + private const int MaxTextBytesPerFile = 100 * 1024; + private const int MaxTotalTextBytes = 250 * 1024; + private const int MaxImageBytes = 1024 * 1024; + + // SAMPLE GUARDRAIL: the formats this sample is willing to forward. The file API itself delivers any attached + // file type. + private static readonly HashSet ImageContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + }; + + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "c", "cpp", "cs", "css", "csv", "go", "h", "html", "java", "js", "json", "jsx", "md", "py", + "rb", "rs", "sh", "sql", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", + }; + + private static readonly Regex TextualContentType = + new(@"\b(json|xml|javascript|yaml|csv|markdown)\b", RegexOptions.Compiled); + + /// + /// SAMPLE GUARDRAIL: decides whether a downloaded file can be sent to the model. + /// + /// The response MIME type is preferred, but the platform-supplied extension is a necessary fallback, and that part + /// is a real file-receive detail rather than a sample preference: Teams commonly omits or misclassifies source + /// files, reporting .ts as video/vnd.dlna.mpeg-tts for example. + /// + public static FileKind Classify(DownloadedFile file, string? extension) + { + string contentType = BaseContentType(file.ContentType); + + if (ImageContentTypes.Contains(contentType)) + { + return FileKind.Image; + } + + if (IsTextContentType(contentType) || GetTextExtension(extension, file.Filename) is not null) + { + return FileKind.Text; + } + + return FileKind.Unsupported; + } + + /// + /// Converts already-downloaded files into model content parts. + /// + /// The conversion itself is the AI integration. The caps it enforces along the way are SAMPLE GUARDRAILs, and each + /// one that drops or shortens a file returns a warning so the user is never left guessing what the model saw. + /// + public static AnalysisRequest Prepare(string userText, IList files) + { + List parts = + [ + new TextContent(string.IsNullOrWhiteSpace(userText) + ? "Please analyze the attached file content." + : userText.Trim()), + ]; + + List warnings = []; + int fileCount = 0; + int totalTextBytes = 0; + + foreach (AnalyzableFile entry in files.Take(MaxFiles)) + { + DownloadedFile downloaded = entry.File; + + if (entry.Kind == FileKind.Image) + { + if (downloaded.Bytes.Length > MaxImageBytes) + { + warnings.Add($"{downloaded.Filename} was not sent to the model because it is larger than 1 MB."); + continue; + } + + parts.Add(new TextContent($"Attached image: {downloaded.Filename}")); + + // FILE RECEIVE: the downloaded bytes are sent inline instead of handing the model the pre-authorized + // tempauth download URL, which is a short-lived credential. + parts.Add(new DataContent(downloaded.Bytes, BaseContentType(downloaded.ContentType))); + fileCount++; + continue; + } + + int remainingBytes = MaxTotalTextBytes - totalTextBytes; + if (remainingBytes <= 0) + { + warnings.Add( + $"{downloaded.Filename} was not sent to the model because the combined text-file limit was reached."); + continue; + } + + int includedBytes = Math.Min(downloaded.Bytes.Length, Math.Min(MaxTextBytesPerFile, remainingBytes)); + string text = System.Text.Encoding.UTF8.GetString(downloaded.Bytes, 0, includedBytes); + bool truncated = includedBytes < downloaded.Bytes.Length; + totalTextBytes += includedBytes; + + List lines = + [ + $"Attached file: {downloaded.Filename}", + string.Empty, + "", + text, + ]; + if (truncated) + { + lines.Add("[File content truncated by the sample.]"); + } + lines.Add(""); + + parts.Add(new TextContent(string.Join('\n', lines))); + + if (truncated) + { + warnings.Add($"{downloaded.Filename} was truncated before being sent to the model."); + } + + fileCount++; + } + + if (files.Count > MaxFiles) + { + warnings.Add( + $"{files.Count - MaxFiles} supported file(s) were not sent to the model because this sample " + + $"analyzes up to {MaxFiles} files per message. Unsupported files are reported separately."); + } + + return new AnalysisRequest(parts, warnings, fileCount); + } + + private static string BaseContentType(string contentType) + => contentType.Split(';', 2)[0].Trim().ToLowerInvariant(); + + private static bool IsTextContentType(string contentType) + => contentType.StartsWith("text/", StringComparison.Ordinal) + || TextualContentType.IsMatch(contentType); + + private static string? GetTextExtension(string? extension, string filename) + { + string normalized = !string.IsNullOrEmpty(extension) + ? extension.TrimStart('.') + : Path.GetExtension(filename).TrimStart('.'); + + return TextExtensions.Contains(normalized) ? normalized : null; + } +} diff --git a/samples/AIFileAnalysisBot/Program.cs b/samples/AIFileAnalysisBot/Program.cs new file mode 100644 index 00000000..670b83a7 --- /dev/null +++ b/samples/AIFileAnalysisBot/Program.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using AIFileAnalysisBot; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Files; + +// Two kinds of code live in this sample, labeled throughout: +// +// - FILE RECEIVE is the Teams SDK file API itself. This is the part worth copying into your own app. +// - SAMPLE GUARDRAIL is this sample deciding what it is willing to forward to a model. Those limits are arbitrary +// product choices, not SDK requirements, and your app should pick its own. +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddTeamsBotApplication(); + +// SAMPLE GUARDRAIL: the file API needs no model, so the sample stays usable without Azure OpenAI settings. Without +// them it answers every file with the metadata card instead of analyzing it, which keeps download, content type, +// scope, and source demonstrable with no model subscription. +string? endpoint = builder.Configuration["AzureOpenAI:Endpoint"]; +string? apiKey = builder.Configuration["AzureOpenAI:ApiKey"]; +string? deployment = builder.Configuration["AzureOpenAI:Deployment"]; +bool aiConfigured = !string.IsNullOrWhiteSpace(endpoint) + && !string.IsNullOrWhiteSpace(apiKey) + && !string.IsNullOrWhiteSpace(deployment); + +if (aiConfigured) +{ + builder.Services.AddSingleton(_ => + new AzureOpenAIClient(new Uri(endpoint!), new ApiKeyCredential(apiKey!)) + .GetChatClient(deployment!) + .AsIChatClient()); + builder.Services.AddSingleton(); +} + +WebApplication webApp = builder.Build(); +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); +AnalysisRunner? runner = webApp.Services.GetService(); +ILogger logger = webApp.Services.GetRequiredService().CreateLogger("AIFileAnalysisBot"); + +const string NoModelNote = + "I downloaded this file, but no model is configured for this sample, so I did not analyze it. " + + "Set the AzureOpenAI values in appsettings to enable analysis."; + +if (runner is null) +{ + logger.LogWarning( + "Azure OpenAI is not configured, so files will be reported but not analyzed. Set AzureOpenAI:Endpoint, " + + "AzureOpenAI:ApiKey, and AzureOpenAI:Deployment to enable analysis."); +} + +teamsApp.OnMessage(async (context, cancellationToken) => +{ + await context.TypingAsync(cancellationToken); + + // FILE RECEIVE: the files attached to this activity. + IList attached = await context.Files.ListAsync(cancellationToken); + if (attached.Count == 0) + { + await context.SendAsync( + runner is not null + ? "Attach one or more files. I analyze text files and images, and describe anything else I cannot read." + : "Attach one or more files. No model is configured, so I will report what I received without analyzing it.", + cancellationToken); + return; + } + + List analyzable = []; + + foreach (IncomingFile file in attached) + { + DownloadedFile downloaded; + try + { + // FILE RECEIVE: download once. Every read below uses this in-memory copy rather than refetching through the + // short-lived Teams download URL. + downloaded = await file.DownloadAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Could not download {FileName}", file.Name); + await context.SendAsync($"I could not download {file.Name}.", cancellationToken); + continue; + } + + // SAMPLE GUARDRAIL: the SDK hands over every attached file regardless of type. This sample is what narrows + // that to the formats it will send on. + if (runner is null) + { + await context.SendAsync( + new MessageActivityInput().AddAdaptiveCardAttachment( + FileCard.Unsupported(file, downloaded, NoModelNote)), + cancellationToken); + continue; + } + + FileKind kind = FileContext.Classify(downloaded, file.Extension); + + if (kind == FileKind.Unsupported) + { + await context.SendAsync( + new MessageActivityInput().AddAdaptiveCardAttachment(FileCard.Unsupported(file, downloaded)), + cancellationToken); + continue; + } + + analyzable.Add(new AnalyzableFile(downloaded, kind)); + } + + if (analyzable.Count == 0) + { + return; + } + + // SAMPLE GUARDRAIL: applies this sample's size and count caps and reports anything it dropped or truncated. + AnalysisRequest analysis = FileContext.Prepare(context.Activity.TextWithoutMentions ?? string.Empty, analyzable); + + foreach (string warning in analysis.Warnings) + { + await context.SendAsync(warning, cancellationToken); + } + + if (analysis.FileCount == 0) + { + return; + } + + if (runner is null) + { + // Not reachable: with no model configured every file already took the metadata-card path above, so nothing + // reaches this point. The check is here to satisfy nullable analysis. + return; + } + + await runner.RunAsync(analysis, TeamsStreamingWriter.CreateFromContext(context), cancellationToken); +}); + +webApp.Run(); diff --git a/samples/AIFileAnalysisBot/Properties/launchSettings.TEMPLATE.json b/samples/AIFileAnalysisBot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 00000000..1c79a166 --- /dev/null +++ b/samples/AIFileAnalysisBot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "AIFileAnalysisBot": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AzureAd__Instance": "https://login.microsoftonline.com/", + "AzureAd__TenantId": "", + "AzureAd__ClientId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "", + "AzureOpenAI__Endpoint": "", + "AzureOpenAI__ApiKey": "", + "AzureOpenAI__Deployment": "" + }, + "applicationUrl": "http://localhost:3978" + } + } +} diff --git a/samples/AIFileAnalysisBot/README.md b/samples/AIFileAnalysisBot/README.md new file mode 100644 index 00000000..568baf80 --- /dev/null +++ b/samples/AIFileAnalysisBot/README.md @@ -0,0 +1,88 @@ +# AI file analysis + +A Teams bot that reads files attached in personal (1:1) chat and sends the ones it understands to Azure OpenAI. + +One message handler covers both paths: + +- **Basic (no LLM)** replies with an Adaptive Card describing any file the sample cannot analyze, showing the metadata the file API exposes and the bytes that were downloaded. +- **AI** converts supported text files and images into model input and streams the analysis back. + +### Reading the code + +Comments label which of two things a given block is doing: + +- **`FILE RECEIVE`** is the Teams SDK file API. This is the part worth copying into your own app. +- **`SAMPLE GUARDRAIL`** is this sample deciding what it will forward to a model: which formats it accepts, how much text it sends, how many files per message, and whether anything is remembered between turns. These are arbitrary product choices, not SDK or Azure OpenAI requirements. Your app should pick its own. + +The distinction matters because most of the code volume here is guardrails. Receiving a file is only `context.Files.ListAsync()` followed by `DownloadAsync()`. + +## Prerequisites + +- .NET +- A Teams bot registration +- A Teams app manifest with `supportsFiles` set to `true` on the bot entry (see [Enable file support in the manifest](#enable-file-support-in-the-manifest)) +- An Azure OpenAI deployment (use a vision-capable model to analyze images). This is optional: without it the example still runs, receives files, and reports each one with an Adaptive Card instead of analyzing it. See [Running without a model](#running-without-a-model). + +## Enable file support in the manifest + +The bot entry in your Teams app manifest must set `supportsFiles` to `true`: + +```json +"bots": [ + { + "botId": "", + "scopes": ["personal"], + "supportsFiles": true + } +] +``` + +Without it, Teams does not enable the attachment UI in the bot's chat, so there is no way to attach a file in the first place and `context.Files.ListAsync()` has nothing to return. + +## Setup + +Copy `Properties/launchSettings.TEMPLATE.json` to `Properties/launchSettings.json` and fill in your bot credentials alongside these settings: + +```json +"AzureOpenAI__Endpoint": "https://.openai.azure.com/", +"AzureOpenAI__ApiKey": "", +"AzureOpenAI__Deployment": "" +``` + +Run: + +```bash +dotnet run --project samples/AIFileAnalysisBot +``` + +## Running without a model + +The file APIs this sample demonstrates do not need a model, so the Azure OpenAI settings above are optional. + +Leave any of them unset and the sample starts in metadata-only mode. It still receives, downloads, and reports every +attached file with the Adaptive Card, showing the resolved content type, byte count, scope, and source, so the whole +file round-trip is demonstrable without a model subscription. Only the analysis step is skipped, and the card says so. + +## What happens to an attached file + +1. `context.Files.ListAsync()` returns the files on the incoming activity. +2. Each file is downloaded once, and that in-memory copy is reused instead of refetching through the short-lived Teams download URL. +3. `FileContext.Classify` sorts each download into `Text`, `Image`, or `Unsupported`. +4. Unsupported files get the basic Adaptive Card. No model call is made for them. +5. Supported files become model content parts and are sent in a single request, and the reply is streamed to Teams. + +Image bytes are sent inline as `DataContent` rather than as a link, so the pre-authorized `tempauth` download URL is never handed to the model. + +## Limits + +The sample accepts up to five files per message. Text input is capped at 100 KB per file and 250 KB per message, and images at 1 MB each. Supported image formats are PNG, JPEG, GIF, and WebP. Anything skipped or truncated produces a message explaining why. + +Because `DownloadAsync()` buffers the whole file first, these caps bound what reaches the model, not network transfer or process memory. + +## Scope + +The AI path is stateless: each message is analyzed on its own, with no conversation memory. That keeps a follow-up question from silently reusing files the user did not attach to it, and keeps images from being resent on every later turn. + +Statelessness here is a **`SAMPLE GUARDRAIL`**, not an SDK or Azure OpenAI constraint. Your app can keep conversation state and reuse previously attached files; this sample opts out so that every analysis is traceable to the files on the message that triggered it. + +There are no tools, citations, feedback, or follow-up suggestions here. See the [`ExtAIBot`](https://github.com/microsoft/teams.net/tree/main/samples/ExtAIBot) sample for those. diff --git a/samples/AIFileAnalysisBot/appsettings.json b/samples/AIFileAnalysisBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/samples/AIFileAnalysisBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +}