Skip to content
Merged
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
1 change: 1 addition & 0 deletions Microsoft.Teams.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<Project Path="samples/A2ABot/A2ABot.csproj" />
<Project Path="samples/Agent365/Agent365.csproj" />
<Project Path="samples/AdaptiveCardTaskModuleBot/AdaptiveCardTaskModuleBot.csproj" />
<Project Path="samples/AIFileAnalysisBot/AIFileAnalysisBot.csproj" />
<Project Path="samples/CommonHandlersBot/CommonHandlersBot.csproj" />
<Project Path="samples/CachingAuthTokens/CachingAuthTokens.csproj" Id="01e6530d-ac7d-47ca-9748-785883eb6c39" />
<Project Path="samples/CompatBot/CompatBot.csproj" Id="39461362-c3d0-41ae-9ed7-f7e1232a8ead" />
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions samples/AIFileAnalysisBot/AIFileAnalysisBot.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

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

<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Teams.Apps\Microsoft.Teams.Apps.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Teams.Cards\Microsoft.Teams.Cards.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
</ItemGroup>

</Project>
64 changes: 64 additions & 0 deletions samples/AIFileAnalysisBot/AnalysisRunner.cs
Original file line number Diff line number Diff line change
@@ -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<AnalysisRunner> 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.
""";

/// <summary>
/// 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.
/// </summary>
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);
}
}
}
83 changes: 83 additions & 0 deletions samples/AIFileAnalysisBot/FileCard.cs
Original file line number Diff line number Diff line change
@@ -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.";

/// <summary>
/// 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.
/// </summary>
/// <param name="file">The incoming file, used for the scope and source facts.</param>
/// <param name="downloaded">The downloaded copy, used for filename, content type, and byte count.</param>
/// <param name="note">
/// 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.
/// </param>
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]}";
}
}
189 changes: 189 additions & 0 deletions samples/AIFileAnalysisBot/FileContext.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Whether this sample can send a downloaded file to the model, and as what.
/// </summary>
internal enum FileKind
{
Image,
Text,
Unsupported,
}

/// <summary>
/// A downloaded file that <see cref="FileContext.Classify"/> accepted, paired with its kind.
/// </summary>
internal sealed record AnalyzableFile(DownloadedFile File, FileKind Kind);

/// <summary>
/// A model request built from the user's message and their analyzable files.
/// </summary>
/// <param name="Content">The content parts sent as the user message.</param>
/// <param name="Warnings">User-facing explanations for files that were skipped or truncated.</param>
/// <param name="FileCount">Number of files whose content reached the model request.</param>
internal sealed record AnalysisRequest(IList<AIContent> Content, IList<string> 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<string> ImageContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
};

private static readonly HashSet<string> 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);

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
public static AnalysisRequest Prepare(string userText, IList<AnalyzableFile> files)
{
List<AIContent> parts =
[
new TextContent(string.IsNullOrWhiteSpace(userText)
? "Please analyze the attached file content."
: userText.Trim()),
];

List<string> 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<string> lines =
[
$"Attached file: {downloaded.Filename}",
string.Empty,
"<file>",
text,
];
if (truncated)
{
lines.Add("[File content truncated by the sample.]");
}
lines.Add("</file>");

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;
}
}
Loading
Loading