Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added HandoverToLiveAgent/.img/solution_import.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

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

</Project>
126 changes: 126 additions & 0 deletions HandoverToLiveAgent/ContosoLiveChatApp/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Runtime.ExceptionServices;
using System.Security;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class ChatController : ControllerBase
{
private readonly ChatStorageService _chatStorage;
private readonly WebhookService _webhookService;
private readonly ILogger<ChatController> _logger;

public ChatController(ChatStorageService chatStorage, WebhookService webhookService, ILogger<ChatController> logger)
{
_chatStorage = chatStorage;
_webhookService = webhookService;
_logger = logger;
}

// GET: api/chat/messages - Get all chat messages
[HttpGet("messages")]
public ActionResult<IEnumerable<ChatMessage>> GetMessages(string? conversationId = null)
{
var messages = _chatStorage.GetAllMessages(conversationId);
return Ok(messages);
}

// POST: api/chat/start - Start a new conversation and return conversation ID
[HttpPost("start")]
public ActionResult StartConversation()
{
try
{
var conversationId = Guid.NewGuid().ToString()[..5];
_logger.LogInformation("Started new conversation with ID: {ConversationId}", conversationId);

_chatStorage.StartConversation(conversationId);

return Ok(new { conversationId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting conversation");
return StatusCode(500, new { error = ex.Message });
}
}

// POST: api/chat/end - End a conversation
[HttpPost("end")]
public ActionResult EndConversation([FromBody] MessageRequest request)
{
try
{
_logger.LogInformation("Ending conversation with ID: {ConversationId}", request.ConversationId);
_chatStorage.EndConversation(request.ConversationId);
return Ok(new { message = "Conversation ended successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error ending conversation");
return StatusCode(500, new { error = ex.Message });
}
}

// POST: api/chat/send - Send a message (from Live Chat to Copilot Studio webhook)
[HttpPost("send")]
public async Task<ActionResult> SendMessage([FromBody] MessageRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return BadRequest(new { error = "Message text cannot be empty" });
}

var message = new ChatMessage
{
ConversationId = request.ConversationId,
Message = request.Message,
Sender = "Contoso Support",
Timestamp = DateTime.UtcNow
};

// Send to webhook first
var (statusCode, errorMessage) = await _webhookService.SendMessageAsync(message);

if (statusCode.HasValue && statusCode >= 200 && statusCode < 300)
{
// Only add to chat history if webhook send was successful
_chatStorage.AddMessage(message.ConversationId, message);
return Ok(new { message = "Message sent successfully", messageId = message.Id, timestamp = message.Timestamp, sender = message.Sender, conversationId = message.ConversationId });
}
else
{
return StatusCode(statusCode ?? 500, new { error = errorMessage });
}
}

// POST: api/chat/receive - Receive a message (from Copilot Studio )
[HttpPost("receive")]
public ActionResult ReceiveMessage([FromBody] MessageRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return BadRequest(new { error = "Message text cannot be empty" });
}

var message = new ChatMessage
{
ConversationId = request.ConversationId,
Message = request.Message,
Sender = request.Sender ?? "Remote",
Timestamp = DateTime.UtcNow
};

_chatStorage.AddMessage(message.ConversationId, message);
_logger.LogInformation("Received message from {Sender}: {Text}", message.Sender, message.Message);

return Ok(new { message = "Message received successfully", messageId = message.Id });
}
}

public class MessageRequest
{
public string ConversationId { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Sender { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
public class ChatMessage
{
public string ConversationId { get; set; } = string.Empty;
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Message { get; set; } = string.Empty;
public string Sender { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
23 changes: 23 additions & 0 deletions HandoverToLiveAgent/ContosoLiveChatApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.ListenAnyIP(5000);
});

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSingleton<ChatStorageService>();
builder.Services.AddHttpClient<WebhookService>();

var app = builder.Build();

var webhookUrl = app.Configuration["WebhookSettings:OutgoingWebhookUrl"];
app.Logger.LogInformation("Webhook URL configured: {WebhookUrl}", webhookUrl);

app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
app.MapControllers();

app.Run();
92 changes: 92 additions & 0 deletions HandoverToLiveAgent/ContosoLiveChatApp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Contoso Live Chat App

A simple live chat application designed as mock live agent handover scenarios with Copilot Studio. This application simulates a live chat support system that can receive conversations from Copilot Studio and send messages back.

## Project Structure

```
ContosoLiveChatApp/
├── Controllers/
│ └── ChatController.cs # API endpoints for chat operations (used by Copilot Studio agent)
├── Models/
│ └── ChatMessage.cs # Chat message data model
├── Services/
│ ├── ChatStorageService.cs # Conversation-based message storage
│ └── WebhookService.cs # Outgoing webhook sender (send messages to Copilot Studio agent)
├── wwwroot/
│ └── index.html # Chat UI with conversation management
├── Program.cs # Application entry point
├── appsettings.json # Configuration (webhook URL)
```

## Configuration

Configure the webhook URL in `appsettings.json`:

```json
{
"WebhookSettings": {
"OutgoingWebhookUrl": "http://localhost:5001/api/livechat/messages"
}
}
```
This endpoint points to the Copilot Studio agent skill URL. The default configuration assumes the HandoverToLiveAgentSample is running on port 5001.

## Running the Application

1. Navigate to the project directory:
```powershell
cd CopilotStudioSamples\HandoverToLiveAgent\ContosoLiveChatApp
```

2. Restore dependencies and run:
```powershell
dotnet run
```

3. Open your browser and navigate to:
```
http://localhost:5000
```

## API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/chat/start` | Start a new conversation, returns `conversationId` |
| `GET` | `/api/chat/messages?conversationId={id}` | Get messages for a conversation |
| `POST` | `/api/chat/send` | Send message to Copilot Studio via webhook |
| `POST` | `/api/chat/receive` | Receive message from Copilot Studio |
| `POST` | `/api/chat/end` | End conversation and clear from memory |

## Architecture

### API Flow

```mermaid
sequenceDiagram
participant CS as Copilot Studio
participant API as Chat API
participant Storage as ChatStorageService
participant UI as Live Chat UI

CS->>API: POST /api/chat/start
API-->>CS: conversationId

Note over API,Storage: Conversation Active

CS->>API: POST /api/chat/receive (from MCS)
API->>Storage: Store message
Storage-->>UI: Display message

UI->>API: POST /api/chat/send (message)
API->>Storage: Store message
API->>CS: Forward via webhook (to MCS)
CS-->>UI: Message delivered

Note over CS,UI: Messages exchanged...

CS->>API: POST /api/chat/end
API->>Storage: Clear conversation
API-->>CS: Conversation ended
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Collections.Concurrent;

public class ChatStorageService
{
private readonly ConcurrentDictionary<string, IList<ChatMessage>> _activeConversations = new();
private readonly ILogger<ChatStorageService> _logger;

public ChatStorageService(ILogger<ChatStorageService> logger)
{
_logger = logger;
}

public void AddMessage(string conversationId, ChatMessage message)
{
_logger.LogInformation("Adding message to conversation ID: {ConversationId}", conversationId);
if (_activeConversations.TryGetValue(conversationId, out var messages))
{
messages.Add(message);
}
else
{
_logger.LogWarning("No active conversation found with ID: {ConversationId}", conversationId);
}
}

public void StartConversation(string conversationId)
{
_activeConversations[conversationId] = new List<ChatMessage>();
_logger.LogInformation("Started conversation with ID: {ConversationId}", conversationId);
}

public void EndConversation(string conversationId)
{
_activeConversations.TryRemove(conversationId, out _);
_logger.LogInformation("Ended conversation with ID: {ConversationId}", conversationId);
}

public IEnumerable<ChatMessage> GetAllMessages(string? conversationId)
{
if (string.IsNullOrEmpty(conversationId))
{
return _activeConversations.Values
.SelectMany(messages => messages)
.OrderBy(m => m.Timestamp);
}

_logger.LogInformation("Retrieving messages for conversation ID: {ConversationId}", conversationId);
if (_activeConversations.TryGetValue(conversationId, out var messages))
{
return messages.OrderBy(m => m.Timestamp);
}
else
{
_logger.LogWarning("No active conversation found with ID: {ConversationId}", conversationId);
return Enumerable.Empty<ChatMessage>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text;
using System.Text.Json;

public class WebhookService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<WebhookService> _logger;

public WebhookService(HttpClient httpClient, IConfiguration configuration, ILogger<WebhookService> logger)
{
_httpClient = httpClient;
_configuration = configuration;
_logger = logger;
}

public async Task<Tuple<int?, string>> SendMessageAsync(ChatMessage message)
{
try
{
var webhookUrl = _configuration["WebhookSettings:OutgoingWebhookUrl"];

if (string.IsNullOrEmpty(webhookUrl))
{
_logger.LogWarning("Webhook URL is not configured");
return new Tuple<int?, string>(null, "Webhook URL is not configured");
}

var json = JsonSerializer.Serialize(message);
var content = new StringContent(json, Encoding.UTF8, "application/json");

var response = await _httpClient.PostAsync(webhookUrl, content);

if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Message sent successfully to webhook: {MessageId}", message.Id);
return new Tuple<int?, string>((int)response.StatusCode, string.Empty);
}
else
{
_logger.LogWarning("Failed to send message to webhook. Status: {StatusCode}", response.StatusCode);
var errorMessage = await response.Content.ReadAsStringAsync();
return new Tuple<int?, string>((int)response.StatusCode, errorMessage);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending message to webhook");
return new Tuple<int?, string>(null, ex.Message);
}
}
}
12 changes: 12 additions & 0 deletions HandoverToLiveAgent/ContosoLiveChatApp/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"WebhookSettings": {
"OutgoingWebhookUrl": "http://localhost:5001/api/livechat/messages"
}
}
Loading