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
24 changes: 24 additions & 0 deletions samples/dotnet/GenesysHandoff/GenesysHandoff.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenesysHandoff", "GenesysHandoff.csproj", "{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B38FA8F3-EFAE-4108-BE23-9CD043AEA775}
EndGlobalSection
EndGlobal
191 changes: 89 additions & 102 deletions samples/dotnet/GenesysHandoff/GenesysHandoffAgent.cs

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions samples/dotnet/GenesysHandoff/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using GenesysHandoff;
using GenesysHandoff.Genesys;
using GenesysHandoff.Services;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Hosting.AspNetCore;
using Microsoft.Agents.Storage;
Expand All @@ -26,9 +27,10 @@
// in a cluster of Agent instances.
builder.Services.AddSingleton<IStorage, MemoryStorage>();

// Add the AgentApplication, which contains the logic for responding to
// user messages.
builder.AddAgent<GenesysHandoffAgent>();
// Register application services
builder.Services.AddSingleton<CopilotClientFactory>();
builder.Services.AddSingleton<ActivityResponseProcessor>();
builder.Services.AddSingleton<ConversationStateManager>();

// Register GenesysService as a singleton.
GenesysService? genesysService = null;
Expand All @@ -39,6 +41,10 @@
return genesysService;
});

// Add the AgentApplication, which contains the logic for responding to
// user messages.
builder.AddAgent<GenesysHandoffAgent>();

// Configure the HTTP request pipeline.

// Add AspNet token validation for Azure Bot Service and Entra. Authentication is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Microsoft.Agents.Core.Models;
using Microsoft.Agents.Core.Serialization;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;

namespace GenesysHandoff.Services
{
/// <summary>
/// Processes incoming activities from Copilot Studio and prepares them for sending to users.
/// </summary>
public class ActivityResponseProcessor
{
private readonly ILogger<ActivityResponseProcessor> _logger;

public ActivityResponseProcessor(ILogger<ActivityResponseProcessor> logger)
{
Comment thread
siddharthh98 marked this conversation as resolved.
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
}

/// <summary>
/// Creates a response activity from the incoming Copilot Studio activity by processing entities and preparing it for sending to the user.
/// </summary>
/// <param name="incomingActivity">The activity received from the Copilot Studio client.</param>
/// <param name="logContext">Optional context string to include in the log message for tracking purposes.</param>
/// <returns>A processed activity ready to be sent to the user with fixed citation entities.</returns>
public IActivity CreateResponseActivity(IActivity incomingActivity, string logContext = "")
{
ArgumentNullException.ThrowIfNull(incomingActivity);

_logger.LogInformation("Activity received from Copilot client{LogContext}",
string.IsNullOrEmpty(logContext) ? "" : $" ({logContext})");

var responseActivity = MessageFactory.CreateMessageActivity(incomingActivity.Text);
responseActivity.Text = CitationUrlCleaner.RemoveCitationUrlsFromTail(responseActivity.Text, incomingActivity.Entities);
responseActivity.TextFormat = incomingActivity.TextFormat;
responseActivity.InputHint = incomingActivity.InputHint;
responseActivity.Attachments = incomingActivity.Attachments;
responseActivity.SuggestedActions = incomingActivity.SuggestedActions;

// Note: MembersAdded, MembersRemoved, ReactionsAdded, and ReactionsRemoved are NOT copied
// These properties are context-specific to the original message and should not be transferred
// to a new response activity

// Copy channel data but remove streamType and streamId if present
if (incomingActivity.ChannelData != null)
{
try
{
var originalChannelData = ProtocolJsonSerializer.ToObject<Dictionary<string, object>>(
ProtocolJsonSerializer.ToJson(incomingActivity.ChannelData));

if (originalChannelData != null)
{
// Create a new mutable dictionary, excluding streamType and streamId
var channelData = new Dictionary<string, object>(originalChannelData.Count);
foreach (var kvp in originalChannelData)
{
if (kvp.Key != "streamType" && kvp.Key != "streamId")
{
channelData[kvp.Key] = kvp.Value;
}
}

if (channelData.Count > 0)
{
responseActivity.ChannelData = channelData;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process channel data. Channel data will be omitted from response.");
}
}

// Fix entities to remove streaminfo and fix citation appearance issues
if (incomingActivity.Entities != null && incomingActivity.Entities.Any())
{
responseActivity.Entities = CitationEntityProcessor.FixCitationEntities(incomingActivity.Entities);
}

_logger.LogInformation("Activity being sent to user{LogContext}",
string.IsNullOrEmpty(logContext) ? "" : $" ({logContext})");

return responseActivity;
}
}
}
92 changes: 92 additions & 0 deletions samples/dotnet/GenesysHandoff/Services/CitationEntityProcessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Microsoft.Agents.Core.Models;
using System;
using System.Collections.Generic;
using System.Linq;

namespace GenesysHandoff.Services
{
/// <summary>
/// Processes and fixes citation entities for proper rendering in Teams and other clients.
/// </summary>
public static class CitationEntityProcessor
{
/// <summary>
/// Filters entities to exclude streaminfo types and fixes invalid citation appearances.
/// Teams requires proper citation structure for interactive citation rendering.
/// </summary>
/// <param name="entities">The original entities from Copilot Studio.</param>
/// <returns>A filtered list of entities with valid citation formatting.</returns>
public static IList<Entity> FixCitationEntities(IList<Entity> entities)
{
Comment thread
siddharthh98 marked this conversation as resolved.
ArgumentNullException.ThrowIfNull(entities);

var filteredEntities = new List<Entity>();
foreach (var entity in entities)
{
// Exclude streaminfo entities
if (entity.Type != null && entity.Type.Equals("streaminfo", StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Process AIEntity citations to ensure proper Teams rendering
if (entity is AIEntity aiEntity)
{
if (aiEntity.Citation != null && aiEntity.Citation.Count > 0)
{
var annotation = new AIEntity();
foreach (var clientCitation in aiEntity.Citation)
{
if (clientCitation.Appearance == null)
{
continue;
}

var clientCitationIconName = GetIconNameOrDefault(clientCitation.Appearance.Image?.Name);

annotation.Citation.Add(new ClientCitation(
clientCitation.Position,
clientCitation.Appearance.Name,
clientCitation.Appearance.Abstract,
clientCitation.Appearance.Text ?? string.Empty,
null,
clientCitation.Appearance.Url,
clientCitationIconName
));
}
filteredEntities.Add(annotation);
}
else
{
filteredEntities.Add(entity);
}
}
Comment thread
siddharthh98 marked this conversation as resolved.
else
{
// Preserve all other entity types (mentions, reactions, etc.)
filteredEntities.Add(entity);
}
}
return filteredEntities;
}

/// <summary>
/// Gets the icon name from the appearance image, defaulting to Image if the value is unknown or invalid.
/// </summary>
private static ClientCitationsIconNameEnum GetIconNameOrDefault(ClientCitationsIconNameEnum? iconName)
{
if (iconName == null)
{
return ClientCitationsIconNameEnum.Image;
}

// Check if the enum value is defined, otherwise use default
if (!Enum.IsDefined(typeof(ClientCitationsIconNameEnum), iconName.Value))
{
return ClientCitationsIconNameEnum.Image;
}

return iconName.Value;
}
}
}
98 changes: 98 additions & 0 deletions samples/dotnet/GenesysHandoff/Services/CitationUrlCleaner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.Agents.Core.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace GenesysHandoff.Services
{
/// <summary>
/// Utility class for removing citation URLs and references from text content.
/// </summary>
public static class CitationUrlCleaner
{
private static readonly string[] CitationHeaders = ["sources:", "references:", "citations:"];
private static readonly Regex CitationLinePattern = new(@"^\s*(\[\d+\]:|\d+[\.\)]|-\s*)\s*https?://", RegexOptions.IgnoreCase | RegexOptions.Compiled);

/// <summary>
/// Removes citation URLs and reference lines from the end of the text.
/// </summary>
public static string RemoveCitationUrlsFromTail(string text, IList<Entity>? entities)
{
if (string.IsNullOrWhiteSpace(text))
return text;

var citationUrls = ExtractCitationUrls(entities);
if (citationUrls.Count == 0)
return text;

// Split on newlines handling both \r\n and \n efficiently
// Split on \n first, then trim any remaining \r from each line
var lines = text.Split('\n')
.Select(line => line.TrimEnd('\r'))
.ToList();

TrimTrailingBlankLines(lines);

int originalCount = lines.Count;
RemoveCitationLinesFromTail(lines, citationUrls);
TrimTrailingBlankLines(lines);

return lines.Count < originalCount ? string.Join("\n", lines) : text;
}

private static HashSet<string> ExtractCitationUrls(IList<Entity>? entities)
{
if (entities == null || entities.Count == 0)
return [];

return entities
.OfType<AIEntity>()
.Where(e => e.Citation != null)
.SelectMany(e => e.Citation)
.Select(c => c.Appearance?.Url)
.Where(u => !string.IsNullOrWhiteSpace(u))
.OfType<string>() // Explicitly filter to non-null strings for type safety
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}

private static void RemoveCitationLinesFromTail(List<string> lines, HashSet<string> citationUrls)
{
while (lines.Count > 0)
{
var lastLine = lines[^1].Trim();

if (IsCitationLine(lastLine, citationUrls))
{
lines.RemoveAt(lines.Count - 1);
}
else
{
break;
}
}
}

private static bool IsCitationLine(string line, HashSet<string> citationUrls)
{
if (string.IsNullOrWhiteSpace(line))
return true;

if (CitationHeaders.Any(h => line.Equals(h, StringComparison.OrdinalIgnoreCase)))
return true;

if (citationUrls.Any(url => line.Contains(url, StringComparison.OrdinalIgnoreCase)))
return true;

return CitationLinePattern.IsMatch(line);
}

private static void TrimTrailingBlankLines(List<string> lines)
{
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
{
lines.RemoveAt(lines.Count - 1);
}
}
}
}
Loading