Skip to content

Commit

Permalink
[C#] feat: add meeting handlers (#771)
Browse files Browse the repository at this point in the history
## Linked issues

closes: #696

## Details

Add handlers for meeting events

#### Change details

Add handlers under `Application.Meetings` for following events:
- application/vnd.microsoft.meetingStart
- application/vnd.microsoft.meetingEnd
- application/vnd.microsoft.meetingParticipantJoin
- application/vnd.microsoft.meetingParticipantLeave

Refactor `InvokeActivityUtilities` to `ActivityUtilities` since the
event activity has `Activity.Value` as well.

Unit tests.

## Attestation Checklist

- [X] My code follows the style guidelines of this project

- I have checked for/fixed spelling, linting, and other errors
- I have commented my code for clarity
- I have made corresponding changes to the documentation (we use
[TypeDoc](https://typedoc.org/) to document our code)
- My changes generate no new warnings
- I have added tests that validates my changes, and provides sufficient
test coverage. I have tested with:
  - Local testing
  - E2E testing in Teams
- New and existing unit tests pass locally with my changes

### Additional information

> Feel free to add other relevant information below
  • Loading branch information
swatDong authored Nov 6, 2023
1 parent 562a782 commit 90463d7
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Schema;
using Microsoft.TeamsAI.Tests.TestUtils;

namespace Microsoft.TeamsAI.Tests.Application
{
public class MeetingsTests
{
[Fact]
public async void Test_OnStart()
{
// Arrange
var adapter = new NotImplementedAdapter();
var turnContexts = CreateMeetingTurnContext("application/vnd.microsoft.meetingStart", adapter);
var app = new Application<TestTurnState, TestTurnStateManager>(new()
{
RemoveRecipientMention = false,
StartTypingTimer = false
});
var ids = new List<string>();
app.Meetings.OnStart((context, _, _, _) =>
{
ids.Add(context.Activity.Id);
return Task.CompletedTask;
});

// Act
foreach (var turnContext in turnContexts)
{
await app.OnTurnAsync(turnContext);
}

// Assert
Assert.Single(ids);
Assert.Equal("test.id", ids[0]);
}

[Fact]
public async void Test_OnEnd()
{
// Arrange
var adapter = new NotImplementedAdapter();
var turnContexts = CreateMeetingTurnContext("application/vnd.microsoft.meetingEnd", adapter);
var app = new Application<TestTurnState, TestTurnStateManager>(new()
{
RemoveRecipientMention = false,
StartTypingTimer = false
});
var ids = new List<string>();
app.Meetings.OnEnd((context, _, _, _) =>
{
ids.Add(context.Activity.Id);
return Task.CompletedTask;
});

// Act
foreach (var turnContext in turnContexts)
{
await app.OnTurnAsync(turnContext);
}

// Assert
Assert.Single(ids);
Assert.Equal("test.id", ids[0]);
}

[Fact]
public async void Test_OnParticipantsJoin()
{
// Arrange
var adapter = new NotImplementedAdapter();
var turnContexts = CreateMeetingTurnContext("application/vnd.microsoft.meetingParticipantJoin", adapter);
var app = new Application<TestTurnState, TestTurnStateManager>(new()
{
RemoveRecipientMention = false,
StartTypingTimer = false
});
var ids = new List<string>();
app.Meetings.OnParticipantsJoin((context, _, _, _) =>
{
ids.Add(context.Activity.Id);
return Task.CompletedTask;
});

// Act
foreach (var turnContext in turnContexts)
{
await app.OnTurnAsync(turnContext);
}

// Assert
Assert.Single(ids);
Assert.Equal("test.id", ids[0]);
}

[Fact]
public async void Test_OnParticipantsLeave()
{
// Arrange
var adapter = new NotImplementedAdapter();
var turnContexts = CreateMeetingTurnContext("application/vnd.microsoft.meetingParticipantLeave", adapter);
var app = new Application<TestTurnState, TestTurnStateManager>(new()
{
RemoveRecipientMention = false,
StartTypingTimer = false
});
var ids = new List<string>();
app.Meetings.OnParticipantsLeave((context, _, _, _) =>
{
ids.Add(context.Activity.Id);
return Task.CompletedTask;
});

// Act
foreach (var turnContext in turnContexts)
{
await app.OnTurnAsync(turnContext);
}

// Assert
Assert.Single(ids);
Assert.Equal("test.id", ids[0]);
}

private static TurnContext[] CreateMeetingTurnContext(string activityName, BotAdapter adapter)
{
return new TurnContext[]
{
new(adapter, new Activity
{
Type = ActivityTypes.Event,
ChannelId = Channels.Msteams,
Name = activityName,
Id = "test.id"
}),
new(adapter, new Activity
{
Type = ActivityTypes.Event,
ChannelId = Channels.Msteams,
Name = "fake.name"
}),
new(adapter, new Activity
{
Type = ActivityTypes.Invoke,
ChannelId = Channels.Msteams,
Name = activityName
}),
new(adapter, new Activity
{
Type = ActivityTypes.Event,
ChannelId = Channels.Webchat,
Name = activityName
}),
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

namespace Microsoft.TeamsAI
{
internal static class InvokeActivityUtilities
internal static class ActivityUtilities
{
public static T? GetInvokeValue<T>(IInvokeActivity activity)
public static T? GetTypedValue<T>(Activity activity)
{
if (activity.Value == null)
if (activity?.Value == null)
{
return default;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ public Application<TState, TTurnStateManager> OnActionExecute(RouteSelector rout
AdaptiveCardInvokeValue? invokeValue;
if (!string.Equals(turnContext.Activity.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(turnContext.Activity.Name, ACTION_INVOKE_NAME)
|| (invokeValue = InvokeActivityUtilities.GetInvokeValue<AdaptiveCardInvokeValue>(turnContext.Activity)) == null
|| (invokeValue = ActivityUtilities.GetTypedValue<AdaptiveCardInvokeValue>(turnContext.Activity)) == null
|| invokeValue.Action == null
|| !string.Equals(invokeValue.Action.Type, ACTION_EXECUTE_TYPE))
{
throw new TeamsAIException($"Unexpected AdaptiveCards.OnActionExecute() triggered for activity type: {turnContext.Activity.Type}");
}

AdaptiveCardInvokeResponse adaptiveCardInvokeResponse = await handler(turnContext, turnState, invokeValue.Action.Data, cancellationToken);
Activity activity = InvokeActivityUtilities.CreateInvokeResponseActivity(adaptiveCardInvokeResponse);
Activity activity = ActivityUtilities.CreateInvokeResponseActivity(adaptiveCardInvokeResponse);
await turnContext.SendActivityAsync(activity, cancellationToken);
};
_app.AddRoute(routeSelector, routeHandler, isInvokeRoute: true);
Expand Down Expand Up @@ -327,7 +327,7 @@ public Application<TState, TTurnStateManager> OnSearch(RouteSelector routeSelect
AdaptiveCardSearchInvokeValue? searchInvokeValue;
if (!string.Equals(turnContext.Activity.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(turnContext.Activity.Name, SEARCH_INVOKE_NAME)
|| (searchInvokeValue = InvokeActivityUtilities.GetInvokeValue<AdaptiveCardSearchInvokeValue>(turnContext.Activity)) == null)
|| (searchInvokeValue = ActivityUtilities.GetTypedValue<AdaptiveCardSearchInvokeValue>(turnContext.Activity)) == null)
{
throw new TeamsAIException($"Unexpected AdaptiveCards.OnSearch() triggered for activity type: {turnContext.Activity.Type}");
}
Expand All @@ -348,7 +348,7 @@ public Application<TState, TTurnStateManager> OnSearch(RouteSelector routeSelect
Results = results
}
};
Activity activity = InvokeActivityUtilities.CreateInvokeResponseActivity(searchInvokeResponse);
Activity activity = ActivityUtilities.CreateInvokeResponseActivity(searchInvokeResponse);
await turnContext.SendActivityAsync(activity, cancellationToken);
}
};
Expand Down Expand Up @@ -398,7 +398,7 @@ private static RouteSelector CreateActionExecuteSelector(Func<string, bool> isMa
return Task.FromResult(
string.Equals(turnContext.Activity.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase)
&& string.Equals(turnContext.Activity.Name, ACTION_INVOKE_NAME)
&& (invokeValue = InvokeActivityUtilities.GetInvokeValue<AdaptiveCardInvokeValue>(turnContext.Activity)) != null
&& (invokeValue = ActivityUtilities.GetTypedValue<AdaptiveCardInvokeValue>(turnContext.Activity)) != null
&& invokeValue.Action != null
&& string.Equals(invokeValue.Action.Type, ACTION_EXECUTE_TYPE)
&& isMatch(invokeValue.Action.Verb));
Expand Down Expand Up @@ -431,7 +431,7 @@ private static RouteSelector CreateSearchSelector(Func<string, bool> isMatch)
return Task.FromResult(
string.Equals(turnContext.Activity.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase)
&& string.Equals(turnContext.Activity.Name, SEARCH_INVOKE_NAME)
&& (searchInvokeValue = InvokeActivityUtilities.GetInvokeValue<AdaptiveCardSearchInvokeValue>(turnContext.Activity)) != null
&& (searchInvokeValue = ActivityUtilities.GetTypedValue<AdaptiveCardSearchInvokeValue>(turnContext.Activity)) != null
&& isMatch(searchInvokeValue.Dataset!));
};
return routeSelector;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public Application(ApplicationOptions<TState, TTurnStateManager> options)
}

AdaptiveCards = new AdaptiveCards<TState, TTurnStateManager>(this);
Meetings = new Meetings<TState, TTurnStateManager>(this);
MessageExtensions = new MessageExtensions<TState, TTurnStateManager>(this);
TaskModules = new TaskModules<TState, TTurnStateManager>(this);

Expand All @@ -80,6 +81,11 @@ public Application(ApplicationOptions<TState, TTurnStateManager> options)
/// </summary>
public AdaptiveCards<TState, TTurnStateManager> AdaptiveCards { get; }

/// <summary>
/// Fluent interface for accessing Meetings' specific features.
/// </summary>
public Meetings<TState, TTurnStateManager> Meetings { get; }

/// <summary>
/// Fluent interface for accessing Message Extensions' specific features.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using Microsoft.Bot.Connector;
using Microsoft.Bot.Schema;
using Microsoft.Bot.Schema.Teams;
using Microsoft.TeamsAI.State;
using Microsoft.TeamsAI.Utilities;

namespace Microsoft.TeamsAI
{
/// <summary>
/// Meetings class to enable fluent style registration of handlers related to Microsoft Teams Meetings.
/// </summary>
/// <typeparam name="TState">The type of the turn state object used by the application.</typeparam>
/// <typeparam name="TTurnStateManager">The type of the turn state manager object used by the application.</typeparam>
public class Meetings<TState, TTurnStateManager>
where TState : ITurnState<StateBase, StateBase, TempState>
where TTurnStateManager : ITurnStateManager<TState>, new()
{
private readonly Application<TState, TTurnStateManager> _app;

/// <summary>
/// Creates a new instance of the Meetings class.
/// </summary>
/// <param name="app"></param> The top level application class to register handlers with.
public Meetings(Application<TState, TTurnStateManager> app)
{
this._app = app;
}

/// <summary>
/// Handles Microsoft Teams meeting start events.
/// </summary>
/// <param name="handler">Function to call when a Microsoft Teams meeting start event activity is received from the connector.</param>
/// <returns>The application instance for chaining purposes.</returns>
public Application<TState, TTurnStateManager> OnStart(MeetingStartHandler<TState> handler)
{
Verify.ParamNotNull(handler);
RouteSelector routeSelector = (context, _) => Task.FromResult
(
string.Equals(context.Activity?.Type, ActivityTypes.Event, StringComparison.OrdinalIgnoreCase)
&& string.Equals(context.Activity?.ChannelId, Channels.Msteams)
&& string.Equals(context.Activity?.Name, "application/vnd.microsoft.meetingStart")
);
RouteHandler<TState> routeHandler = async (turnContext, turnState, cancellationToken) =>
{
MeetingStartEventDetails meeting = ActivityUtilities.GetTypedValue<MeetingStartEventDetails>(turnContext.Activity) ?? new();
await handler(turnContext, turnState, meeting, cancellationToken);
};
_app.AddRoute(routeSelector, routeHandler, isInvokeRoute: false);
return _app;
}

/// <summary>
/// Handles Microsoft Teams meeting end events.
/// </summary>
/// <param name="handler">Function to call when a Microsoft Teams meeting end event activity is received from the connector.</param>
/// <returns>The application instance for chaining purposes.</returns>
public Application<TState, TTurnStateManager> OnEnd(MeetingEndHandler<TState> handler)
{
Verify.ParamNotNull(handler);
RouteSelector routeSelector = (context, _) => Task.FromResult
(
string.Equals(context.Activity?.Type, ActivityTypes.Event, StringComparison.OrdinalIgnoreCase)
&& string.Equals(context.Activity?.ChannelId, Channels.Msteams)
&& string.Equals(context.Activity?.Name, "application/vnd.microsoft.meetingEnd")
);
RouteHandler<TState> routeHandler = async (turnContext, turnState, cancellationToken) =>
{
MeetingEndEventDetails meeting = ActivityUtilities.GetTypedValue<MeetingEndEventDetails>(turnContext.Activity) ?? new();
await handler(turnContext, turnState, meeting, cancellationToken);
};
_app.AddRoute(routeSelector, routeHandler, isInvokeRoute: false);
return _app;
}

/// <summary>
/// Handles Microsoft Teams meeting participants join events.
/// </summary>
/// <param name="handler">Function to call when a Microsoft Teams meeting participants join event activity is received from the connector.</param>
/// <returns>The application instance for chaining purposes.</returns>
public Application<TState, TTurnStateManager> OnParticipantsJoin(MeetingParticipantsEventHandler<TState> handler)
{
Verify.ParamNotNull(handler);
RouteSelector routeSelector = (context, _) => Task.FromResult
(
string.Equals(context.Activity?.Type, ActivityTypes.Event, StringComparison.OrdinalIgnoreCase)
&& string.Equals(context.Activity?.ChannelId, Channels.Msteams)
&& string.Equals(context.Activity?.Name, "application/vnd.microsoft.meetingParticipantJoin")
);
RouteHandler<TState> routeHandler = async (turnContext, turnState, cancellationToken) =>
{
MeetingParticipantsEventDetails meeting = ActivityUtilities.GetTypedValue<MeetingParticipantsEventDetails>(turnContext.Activity) ?? new();
await handler(turnContext, turnState, meeting, cancellationToken);
};
_app.AddRoute(routeSelector, routeHandler, isInvokeRoute: false);
return _app;
}

/// <summary>
/// Handles Microsoft Teams meeting participants leave events.
/// </summary>
/// <param name="handler">Function to call when a Microsoft Teams meeting participants leave event activity is received from the connector.</param>
/// <returns>The application instance for chaining purposes.</returns>
public Application<TState, TTurnStateManager> OnParticipantsLeave(MeetingParticipantsEventHandler<TState> handler)
{
Verify.ParamNotNull(handler);
RouteSelector routeSelector = (context, _) => Task.FromResult
(
string.Equals(context.Activity?.Type, ActivityTypes.Event, StringComparison.OrdinalIgnoreCase)
&& string.Equals(context.Activity?.ChannelId, Channels.Msteams)
&& string.Equals(context.Activity?.Name, "application/vnd.microsoft.meetingParticipantLeave")
);
RouteHandler<TState> routeHandler = async (turnContext, turnState, cancellationToken) =>
{
MeetingParticipantsEventDetails meeting = ActivityUtilities.GetTypedValue<MeetingParticipantsEventDetails>(turnContext.Activity) ?? new();
await handler(turnContext, turnState, meeting, cancellationToken);
};
_app.AddRoute(routeSelector, routeHandler, isInvokeRoute: false);
return _app;
}
}
}
Loading

0 comments on commit 90463d7

Please sign in to comment.