diff --git a/.azdo/cd-core.yaml b/.azdo/cd-core.yaml index afadfdf6..0bd65534 100644 --- a/.azdo/cd-core.yaml +++ b/.azdo/cd-core.yaml @@ -2,10 +2,9 @@ pr: branches: include: - next/* - # Uncomment and edit the following lines to add path filters for PRs in the future - # paths: - # include: - # - core/** + paths: + include: + - core/** trigger: branches: @@ -55,7 +54,7 @@ stages: - task: NuGetCommand@2 displayName: 'Push NuGet Packages' - condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/next/core')) + condition: eq(variables['PushToADOFeed'], true) inputs: command: push packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4e99def3..9cc0b875 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,18 @@ "bicepVersion": "latest" }, "ghcr.io/dotnet/aspire-devcontainer-feature/dotnetaspire:1": { - "version": "9.0" + "version": "latest" + }, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "10.0" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "azureDnsAutoDetection": true, + "installDockerBuildx": true, + "installDockerComposeSwitch": true, + "version": "latest", + "dockerDashComposeVersion": "v2" } } diff --git a/.github/workflows/core-ci.yaml b/.github/workflows/core-ci.yaml new file mode 100644 index 00000000..845f09f9 --- /dev/null +++ b/.github/workflows/core-ci.yaml @@ -0,0 +1,40 @@ +name: Core-CI +permissions: + contents: read + pull-requests: write + +on: + push: + branches: [ next/core ] + pull_request: + branches: [ next/core ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: core + + - name: Build Core + run: dotnet build --no-restore + working-directory: core + + - name: Test + run: dotnet test --no-build + working-directory: core + + - name: Build Core Tests + run: dotnet build + working-directory: core/test \ No newline at end of file diff --git a/.github/workflows/core-test.yaml b/.github/workflows/core-test.yaml new file mode 100644 index 00000000..9ba13ad0 --- /dev/null +++ b/.github/workflows/core-test.yaml @@ -0,0 +1,41 @@ +name: Core-Test +permissions: + contents: read + pull-requests: write + +on: + workflow_dispatch: + +jobs: + build-and-test: + runs-on: ubuntu-latest + environment: test_tenant + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: core + + - name: Build + run: dotnet build --no-restore + working-directory: core + + - name: Test + run: dotnet test --no-build test/Microsoft.Bot.Core.Tests/Microsoft.Bot.Core.Tests.csproj + working-directory: core + env: + AzureAd__Instance: 'https://login.microsoftonline.com/' + AzureAd__ClientId: 'aabdbd62-bc97-4afb-83ee-575594577de5' + AzureAd__TenantId: '56653e9d-2158-46ee-90d7-675c39642038' + AzureAd__Scope: 'https://api.botframework.com/.default' + AzureAd__ClientCredentials__0__SourceType: 'ClientSecret' + AzureAd__ClientCredentials__0__ClientSecret: ${{ secrets.CLIENT_SECRET}} \ No newline at end of file diff --git a/core/.claude/settings.local.json b/core/.claude/settings.local.json new file mode 100644 index 00000000..dcf34652 --- /dev/null +++ b/core/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build:*)", + "Bash(dotnet test:*)" + ] + } +} diff --git a/core/.editorconfig b/core/.editorconfig new file mode 100644 index 00000000..c25f7d71 --- /dev/null +++ b/core/.editorconfig @@ -0,0 +1,40 @@ +root = true + +# All files +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# C# files +[*.cs] +indent_style = space +indent_size = 4 +nullable = enable +#dotnet_diagnostic.CS1591.severity = none ## Suppress missing XML comment warnings + +#### Nullable Reference Types #### +# Make nullable warnings strict +dotnet_diagnostic.CS8600.severity = error +dotnet_diagnostic.CS8602.severity = error +dotnet_diagnostic.CS8603.severity = error +dotnet_diagnostic.CS8604.severity = error +dotnet_diagnostic.CS8618.severity = error # Non-nullable field uninitialized + +# Code quality rules +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_diagnostic.IDE0079.severity = warning + +#### Coding conventions #### +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true + +file_header_template = Copyright (c) Microsoft Corporation.\nLicensed under the MIT License. + +[samples/**/*.cs] +dotnet_diagnostic.CA1848.severity = none # Suppress Logger perfomance in samples + +# Test projects can be more lenient +[tests/**/*.cs] +dotnet_diagnostic.CS8602.severity = warning diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..18b6c1e3 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,7 @@ +launchSettings.json +appsettings.Development.json +*.runsettings + +# Web build output and dependencies +**/Web/bin/ +**/Web/node_modules/ \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 00000000..d28509f4 --- /dev/null +++ b/core/README.md @@ -0,0 +1,137 @@ +# Microsoft.Teams.Bot.Core + +Bot Core implements the Activity Protocol, including schema, conversation client, user token client, and support for Bot and Agentic Identities. + +## Design Principles + +- Loose schema. `TeamsActivity` contains only the strictly required fields for Conversation Client, additional fields are captured as a Dictionary with JsonExtensionData attributes. +- Simple Serialization. `TeamsActivity` can be serialized/deserialized without any custom logic, and trying to avoid custom converters as much as possible. +- Extensible schema. Fields subject to extension, such as `ChannelData` must define their own `Properties` to allow serialization of unknown fields. Use of generics to allow additional types that are not defined in the Core Library. +- Auth based on MSAL. Token acquisition done on top of MSAL +- Respect ASP.NET DI. `TeamsBotApplication` dependencies are configured based on .NET ServiceCollection extensions, reusing the existing `HttpClient` +- Respect ILogger and IConfiguration. + +## Samples + +### Extensible Activity + +```cs +public class MyChannelData : ChannelData +{ + [JsonPropertyName("customField")] + public string? CustomField { get; set; } + + [JsonPropertyName("myChannelId")] + public string? MyChannelId { get; set; } +} + +public class MyCustomChannelDataActivity : TeamsActivity +{ + [JsonPropertyName("channelData")] + public new MyChannelData? ChannelData { get; set; } +} + +[Fact] +public void Deserialize_CustomChannelDataActivity() +{ + string json = """ + { + "type": "message", + "channelData": { + "customField": "customFieldValue", + "myChannelId": "12345" + } + } + """; + var deserializedActivity = TeamsActivity.FromJsonString(json); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); +} +``` + +> Note `FromJsonString` lives in `TeamsActivity`, and there is no need to override. + + +### Basic Bot Application Usage + +```cs +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + await context.SendTypingActivityAsync(cancellationToken); + + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithText(replyText) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); +}; + +teamsApp.Run(); +``` + +## Testing in Teams + +Need to create a Teams Application, configure it in ABS and capture `TenantId`, `ClientId` and `ClientSecret`. Provide those values as + +```json +{ + "AzureAd" : { + "Instance" : "https://login.microsoftonline.com/", + "TenantId" : "", + "ClientId" : "", + "Scope" : "https://api.botframework.com/.default", + "ClientCredentials" : [ + { + "SourceType" : "ClientSecret", + "ClientSecret" : "" + } + ] + } +} +``` + +or as env vars, using the IConfiguration Environment Configuration Provider: + +```env + AzureAd__Instance=https://login.microsoftonline.com/ + AzureAd__TenantId= + AzureAd__ClientId= + AzureAd__Scope=https://api.botframework.com/.default + AzureAd__ClientCredentials__0__SourceType=ClientSecret + AzureAd__ClientCredentials__0__ClientSecret= +``` + + + +## Testing in localhost (anonymous) + +When not providing MSAL configuration all the communication will happen as anonymous REST calls, suitable for localhost testing. + +### Install Playground + +Linux +``` +curl -s https://raw.githubusercontent.com/OfficeDev/microsoft-365-agents-toolkit/dev/.github/scripts/install-agentsplayground-linux.sh | bash +``` + +Windows +``` +winget install m365agentsplayground +``` + + +### Run Scenarios + +``` +dotnet samples/scenarios/middleware.cs -- --urls "http://localhost:3978" +``` diff --git a/core/bot_icon.png b/core/bot_icon.png new file mode 100644 index 00000000..37c81be7 Binary files /dev/null and b/core/bot_icon.png differ diff --git a/core/core.slnx b/core/core.slnx new file mode 100644 index 00000000..96a54703 --- /dev/null +++ b/core/core.slnx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/docs/Architecture.md b/core/docs/Architecture.md new file mode 100644 index 00000000..846bc76c --- /dev/null +++ b/core/docs/Architecture.md @@ -0,0 +1,938 @@ +# Teams Bot SDK Architecture Documentation + +## Overview + +The Teams Bot SDK consists of three layered projects that provide a modern, efficient, and backward-compatible framework for building Microsoft Teams bots. + +```mermaid +graph TB + subgraph "Application Layer" + UserBot[User Bot Application] + end + + subgraph "SDK Layers" + Compat[Microsoft.Teams.Bot.Compat
Bot Framework v4 Compatibility] + Apps[Microsoft.Teams.Bot.Apps
Teams-Specific Features] + Core[Microsoft.Teams.Bot.Core
Core Bot Infrastructure] + end + + subgraph "External Dependencies" + BotFramework[Bot Framework v4 SDK] + TeamsServices[Microsoft Teams Services] + end + + UserBot --> Compat + UserBot --> Apps + Compat --> Apps + Compat --> BotFramework + Apps --> Core + Core --> TeamsServices + + style Core fill:#e1f5ff + style Apps fill:#fff4e1 + style Compat fill:#ffe1f5 +``` + +--- + +## 1. Microsoft.Teams.Bot.Core + +**Purpose**: Provides the foundational infrastructure for building Teams bots with a clean, modern API focused on performance and System.Text.Json serialization. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Core Components" + BotApp[BotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + HttpClient[BotHttpClient] + end + + subgraph "Schema Layer" + CoreActivity[CoreActivity] + AgenticId[AgenticIdentity] + ConvAccount[ConversationAccount] + JsonContext[CoreActivityJsonContext] + end + + subgraph "Middleware Pipeline" + TurnMW[TurnMiddleware] + CustomMW[ITurnMiddleware] + end + + subgraph "Hosting" + AuthHandler[BotAuthenticationHandler] + Extensions[AddBotApplicationExtensions] + Config[BotConfig] + end + + BotApp --> TurnMW + BotApp --> ConvClient + BotApp --> TokenClient + ConvClient --> HttpClient + TokenClient --> HttpClient + TurnMW --> CustomMW + BotApp --> CoreActivity + CoreActivity --> JsonContext + + style BotApp fill:#4a90e2 + style CoreActivity fill:#7ed321 + style TurnMW fill:#f5a623 +``` + +### Core Patterns + +#### 1. **Middleware Pipeline Pattern** + +The middleware pipeline allows processing activities through a chain of handlers. + +```mermaid +sequenceDiagram + participant HTTP as HTTP Request + participant BotApp as BotApplication + participant Pipeline as TurnMiddleware + participant MW1 as Middleware 1 + participant MW2 as Middleware 2 + participant Handler as OnActivity Handler + + HTTP->>BotApp: ProcessAsync(HttpContext) + BotApp->>BotApp: Deserialize CoreActivity + BotApp->>Pipeline: RunPipelineAsync(activity) + Pipeline->>MW1: OnTurnAsync(activity, next) + MW1->>Pipeline: next(activity) + Pipeline->>MW2: OnTurnAsync(activity, next) + MW2->>Pipeline: next(activity) + Pipeline->>Handler: Invoke(activity) + Handler-->>Pipeline: Complete + Pipeline-->>BotApp: Complete + BotApp-->>HTTP: Response +``` + +**Key Classes**: +- `TurnMiddleware`: Manages the middleware pipeline execution +- `ITurnMiddleware`: Interface for custom middleware components +- `BotApplication`: Orchestrates activity processing + +#### 2. **Client Pattern** + +Separate clients handle different aspects of bot communication. + +```mermaid +graph LR + subgraph "Client Layer" + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + end + + subgraph "HTTP Layer" + BotHttpClient[BotHttpClient] + RequestOpts[BotRequestOptions] + end + + subgraph "Services" + ConvAPI["/v3/conversations"] + TokenAPI["/api/usertoken"] + end + + ConvClient --> BotHttpClient + TokenClient --> BotHttpClient + BotHttpClient --> RequestOpts + BotHttpClient --> ConvAPI + BotHttpClient --> TokenAPI + + style ConvClient fill:#4a90e2 + style TokenClient fill:#4a90e2 +``` + +**Key Features**: +- `ConversationClient`: Manages conversation operations (send, reply, get members) +- `UserTokenClient`: Handles OAuth token operations +- `BotHttpClient`: Centralized HTTP client with authentication and retry logic +- `BotRequestOptions`: Configures requests with authentication and custom headers + +#### 3. **Schema with Source Generation** + +Uses System.Text.Json source generators for optimal performance. + +```csharp +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(ConversationAccount))] +internal partial class CoreActivityJsonContext : JsonSerializerContext +{ +} +``` + +**Benefits**: +- Zero-allocation JSON serialization +- AOT (Ahead-of-Time) compilation support +- Faster startup time +- Smaller deployment size + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `BotApplication` | Main entry point for processing activities | Facade | +| `ConversationClient` | Manages conversation operations | Client | +| `UserTokenClient` | Handles user authentication tokens | Client | +| `BotHttpClient` | Centralized HTTP communication | Client | +| `TurnMiddleware` | Executes middleware pipeline | Chain of Responsibility | +| `CoreActivity` | Activity model with source generation | DTO | +| `AgenticIdentity` | Authentication identity for API calls | DTO | +| `BotAuthenticationHandler` | JWT authentication for ASP.NET Core | Authentication Handler | + +### Configuration + +```csharp +services.AddBotApplication(configuration); +// Registers: +// - BotApplication (Singleton) +// - ConversationClient (Singleton) +// - UserTokenClient (Singleton) +// - BotHttpClient (Singleton) +// - Authentication handlers +``` + +--- + +## 2. Microsoft.Teams.Bot.Apps + +**Purpose**: Extends Core with Teams-specific features, handlers, and the TeamsApiClient for advanced Teams operations. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Application Layer" + TeamsBotApp[TeamsBotApplication] + Builder[TeamsBotApplicationBuilder] + end + + subgraph "Handler System" + MsgHandler[MessageHandler] + ConvHandler[ConversationUpdateHandler] + InvokeHandler[InvokeHandler] + InstallHandler[InstallationUpdateHandler] + ReactionHandler[MessageReactionHandler] + end + + subgraph "Teams API Client" + TeamsAPI[TeamsApiClient] + MeetingOps[Meeting Operations] + TeamOps[Team Operations] + BatchOps[Batch Operations] + end + + subgraph "Schema Layer" + TeamsActivity[TeamsActivity] + TeamsChannelData[TeamsChannelData] + TeamsAttachment[TeamsAttachment] + Entities[Entity Types] + end + + subgraph "Context" + Context[Context] + end + + TeamsBotApp --> TeamsAPI + TeamsBotApp --> MsgHandler + TeamsBotApp --> ConvHandler + TeamsBotApp --> InvokeHandler + TeamsBotApp --> InstallHandler + TeamsBotApp --> ReactionHandler + + MsgHandler --> Context + ConvHandler --> Context + InvokeHandler --> Context + + TeamsAPI --> MeetingOps + TeamsAPI --> TeamOps + TeamsAPI --> BatchOps + + TeamsBotApp --> TeamsActivity + TeamsActivity --> TeamsChannelData + + style TeamsBotApp fill:#5856d6 + style TeamsAPI fill:#ff9500 + style Context fill:#34c759 +``` + +### Core Patterns + +#### 1. **Handler Pattern with Typed Arguments** + +Teams-specific activities are routed to typed handlers. + +```mermaid +sequenceDiagram + participant Core as BotApplication + participant Teams as TeamsBotApplication + participant Handler as MessageHandler + participant UserCode as User Handler + + Core->>Teams: OnActivity(CoreActivity) + Teams->>Teams: Convert to TeamsActivity + Teams->>Teams: Create Context + Teams->>Handler: Invoke(MessageArgs, Context) + Handler->>UserCode: Execute(args, context) + UserCode-->>Handler: Complete + Handler-->>Teams: Complete + Teams-->>Core: Complete +``` + +**Handler Types**: +```csharp +public delegate Task MessageHandler(MessageArgs args, Context context, CancellationToken ct); +public delegate Task ConversationUpdateHandler(ConversationUpdateArgs args, Context context, CancellationToken ct); +public delegate Task InvokeHandler(Context context, CancellationToken ct); +public delegate Task InstallationUpdateHandler(InstallationUpdateArgs args, Context context, CancellationToken ct); +public delegate Task MessageReactionHandler(MessageReactionArgs args, Context context, CancellationToken ct); +``` + +#### 2. **Builder Pattern for Application Configuration** + +Fluent API for configuring Teams bot applications. + +```mermaid +graph LR + Start[TeamsBotApplicationBuilder] --> OnMsg[OnMessage] + OnMsg --> OnConv[OnConversationUpdate] + OnConv --> OnInvoke[OnInvoke] + OnInvoke --> OnInstall[OnInstallationUpdate] + OnInstall --> OnReact[OnMessageReaction] + OnReact --> Build[Build] + Build --> App[TeamsBotApplication] + + style Start fill:#5856d6 + style App fill:#5856d6 +``` + +**Usage**: +```csharp +var builder = new TeamsBotApplicationBuilder() + .OnMessage(async (args, context, ct) => { + await context.SendActivityAsync("Hello!"); + }) + .OnConversationUpdate(async (args, context, ct) => { + // Handle member added/removed + }) + .OnInvoke(async (context, ct) => { + return new CoreInvokeResponse { Status = 200 }; + }); + +var app = builder.Build(services); +``` + +#### 3. **Context Pattern** + +Provides a rich context object for bot operations. + +```mermaid +graph TB + Context[Context] + + Context --> Activity[TeamsActivity] + Context --> BotApp[TeamsBotApplication] + Context --> Conv[ConversationClient] + Context --> Token[UserTokenClient] + Context --> Teams[TeamsApiClient] + + Context --> Send[SendActivityAsync] + Context --> Reply[ReplyAsync] + Context --> Update[UpdateActivityAsync] + Context --> Delete[DeleteActivityAsync] + + style Context fill:#34c759 +``` + +**Key Features**: +- Encapsulates current activity and bot application +- Provides convenience methods for common operations +- Access to all clients (Conversation, Token, Teams) +- Simplified response methods + +#### 4. **Teams API Client Pattern** + +Specialized client for Teams-specific operations. + +```mermaid +graph TB + subgraph "TeamsApiClient" + Client[TeamsApiClient] + end + + subgraph "Meeting Operations" + FetchMeeting[FetchMeetingInfoAsync] + FetchParticipant[FetchParticipantAsync] + SendNotification[SendMeetingNotificationAsync] + end + + subgraph "Team Operations" + FetchTeam[FetchTeamDetailsAsync] + FetchChannels[FetchChannelListAsync] + end + + subgraph "Batch Operations" + SendToUsers[SendMessageToListOfUsersAsync] + SendToChannels[SendMessageToListOfChannelsAsync] + SendToTeam[SendMessageToAllUsersInTeamAsync] + SendToTenant[SendMessageToAllUsersInTenantAsync] + GetOpState[GetOperationStateAsync] + GetFailed[GetPagedFailedEntriesAsync] + Cancel[CancelOperationAsync] + end + + Client --> FetchMeeting + Client --> FetchParticipant + Client --> SendNotification + Client --> FetchTeam + Client --> FetchChannels + Client --> SendToUsers + Client --> SendToChannels + Client --> SendToTeam + Client --> SendToTenant + Client --> GetOpState + Client --> GetFailed + Client --> Cancel + + style Client fill:#ff9500 +``` + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `TeamsBotApplication` | Teams-specific bot application | Specialization | +| `TeamsBotApplicationBuilder` | Fluent configuration API | Builder | +| `TeamsApiClient` | Teams-specific API operations | Client | +| `Context` | Rich context for handlers | Context Object | +| `TeamsActivity` | Teams-enhanced activity model | DTO | +| `MessageHandler` | Delegate for message handling | Handler | +| `ConversationUpdateHandler` | Delegate for conversation updates | Handler | +| `InvokeHandler` | Delegate for invoke activities | Handler | +| `TeamsChannelData` | Teams-specific channel data | DTO | +| `Entity` | Base class for activity entities | DTO | + +### REST API Endpoints + +| Operation | Endpoint | Description | +|-----------|----------|-------------| +| Meeting Info | `GET /v1/meetings/{meetingId}` | Get meeting details | +| Participant | `GET /v1/meetings/{meetingId}/participants/{participantId}` | Get participant info | +| Notification | `POST /v1/meetings/{meetingId}/notification` | Send in-meeting notification | +| Team Details | `GET /v3/teams/{teamId}` | Get team information | +| Channels | `GET /v3/teams/{teamId}/channels` | List team channels | +| Batch Users | `POST /v3/batch/conversation/users/` | Message multiple users | +| Batch Channels | `POST /v3/batch/conversation/channels/` | Message multiple channels | +| Batch Team | `POST /v3/batch/conversation/team/` | Message all team members | +| Batch Tenant | `POST /v3/batch/conversation/tenant/` | Message entire tenant | +| Operation State | `GET /v3/batch/conversation/{operationId}` | Get batch operation status | +| Failed Entries | `GET /v3/batch/conversation/failedentries/{operationId}` | Get failed batch entries | +| Cancel Operation | `DELETE /v3/batch/conversation/{operationId}` | Cancel batch operation | + +### Configuration + +```csharp +services.AddTeamsBotApplication(configuration); +// Registers everything from Core plus: +// - TeamsBotApplication (Singleton) +// - TeamsApiClient (Singleton) +// - IHttpContextAccessor +``` + +--- + +## 3. Microsoft.Teams.Bot.Compat + +**Purpose**: Provides backward compatibility with Bot Framework v4 SDK, allowing existing bots to migrate incrementally to the new Teams SDK. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Compatibility Layer" + CompatAdapter[CompatAdapter] + CompatBotAdapter[CompatBotAdapter] + CompatMiddleware[CompatAdapterMiddleware] + end + + subgraph "Client Adapters" + CompatConnector[CompatConnectorClient] + CompatConversations[CompatConversations] + CompatUserToken[CompatUserTokenClient] + end + + subgraph "Static Helpers" + CompatTeamsInfo[CompatTeamsInfo] + CompatActivity[CompatActivity Extensions] + end + + subgraph "Bot Framework v4" + BFAdapter[IBotFrameworkHttpAdapter] + BFBot[IBot] + BFMiddleware[IMiddleware] + TurnContext[ITurnContext] + end + + subgraph "Teams SDK" + TeamsBotApp[TeamsBotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + TeamsAPI[TeamsApiClient] + end + + CompatAdapter -.implements.-> BFAdapter + CompatMiddleware -.implements.-> ITurnMiddleware + + CompatAdapter --> TeamsBotApp + CompatAdapter --> CompatBotAdapter + CompatAdapter --> CompatMiddleware + + CompatConnector --> CompatConversations + CompatConversations --> ConvClient + CompatUserToken --> TokenClient + + CompatTeamsInfo --> ConvClient + CompatTeamsInfo --> TeamsAPI + CompatTeamsInfo --> CompatActivity + + BFBot --> TurnContext + TurnContext --> CompatConnector + + style CompatAdapter fill:#ff2d55 + style CompatTeamsInfo fill:#ff2d55 +``` + +### Core Patterns + +#### 1. **Adapter Pattern** + +Bridges Bot Framework v4 interfaces to Teams SDK implementations. + +```mermaid +sequenceDiagram + participant BF as Bot Framework Bot (IBot) + participant Adapter as CompatAdapter + participant Core as TeamsBotApplication + participant Handler as User Handler + + BF->>Adapter: ProcessAsync(request, response) + Adapter->>Adapter: Register OnActivity handler + Adapter->>Core: ProcessAsync(HttpContext) + Core->>Core: Process CoreActivity + Core->>Adapter: OnActivity callback + Adapter->>Adapter: Convert CoreActivity to Activity + Adapter->>Adapter: Create TurnContext + Adapter->>Adapter: Add clients to TurnState + Adapter->>BF: bot.OnTurnAsync(turnContext) + BF->>Handler: User code executes + Handler-->>BF: Complete + BF-->>Adapter: Complete + Adapter-->>Core: Complete +``` + +**Key Adaptations**: +- `IBotFrameworkHttpAdapter` → `TeamsBotApplication` +- `IBot.OnTurnAsync` → `BotApplication.OnActivity` +- `ITurnContext` → `CoreActivity` +- `IConnectorClient` → `ConversationClient` +- `UserTokenClient` → `UserTokenClient` + +#### 2. **Wrapper Pattern for Clients** + +Wraps Core SDK clients to implement Bot Framework v4 interfaces. + +```mermaid +graph TB + subgraph "Bot Framework Interfaces" + IConnector[IConnectorClient] + IConversations[IConversations] + IUserToken[UserTokenClient BF] + end + + subgraph "Compatibility Wrappers" + CompatConn[CompatConnectorClient] + CompatConv[CompatConversations] + CompatToken[CompatUserTokenClient] + end + + subgraph "Core SDK Clients" + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + end + + CompatConn -.implements.-> IConnector + CompatConv -.implements.-> IConversations + CompatToken -.implements.-> IUserToken + + CompatConn --> CompatConv + CompatConv --> ConvClient + CompatToken --> TokenClient + + style CompatConn fill:#ff3b30 + style CompatConv fill:#ff3b30 + style CompatToken fill:#ff3b30 +``` + +#### 3. **Static Helper Adaptation Pattern** + +Replicates Bot Framework TeamsInfo static methods using Core SDK. + +```mermaid +graph LR + subgraph "Bot Framework v4" + TeamsInfo[TeamsInfo static class] + end + + subgraph "Compatibility Layer" + CompatTeamsInfo[CompatTeamsInfo static class] + Conversions[CompatActivity Extensions] + end + + subgraph "Core SDK" + ConvClient[ConversationClient] + TeamsAPI[TeamsApiClient] + end + + TeamsInfo -.replicated by.-> CompatTeamsInfo + + CompatTeamsInfo --> ConvClient + CompatTeamsInfo --> TeamsAPI + CompatTeamsInfo --> Conversions + + Conversions --> JSONRoundTrip["JSON Round-Trip Serialization"] + Conversions --> DirectMap["Direct Property Mapping"] + + style CompatTeamsInfo fill:#ff9500 +``` + +**Key Methods** (19 total): +- Member operations: GetMemberAsync, GetPagedMembersAsync, etc. +- Meeting operations: GetMeetingInfoAsync, SendMeetingNotificationAsync +- Team operations: GetTeamDetailsAsync, GetTeamChannelsAsync +- Batch operations: SendMessageToListOfUsersAsync, GetOperationStateAsync + +#### 4. **Middleware Bridge Pattern** + +Allows Bot Framework middleware to work with Core SDK middleware pipeline. + +```mermaid +sequenceDiagram + participant Core as Core Pipeline + participant Bridge as CompatAdapterMiddleware + participant BFMiddleware as Bot Framework Middleware + participant Next as Next Handler + + Core->>Bridge: OnTurnAsync(activity, next) + Bridge->>Bridge: Convert CoreActivity to Activity + Bridge->>Bridge: Create TurnContext + Bridge->>BFMiddleware: OnTurnAsync(turnContext, nextDelegate) + BFMiddleware->>Next: nextDelegate() + Next-->>BFMiddleware: Complete + BFMiddleware-->>Bridge: Complete + Bridge->>Core: await next(activity) + Core-->>Bridge: Complete +``` + +#### 5. **Model Conversion Pattern** + +Two strategies for converting between Bot Framework and Core models: + +**Strategy 1: Direct Property Mapping** +```csharp +public static TeamsChannelAccount ToCompatTeamsChannelAccount( + this TeamsConversationAccount account) +{ + return new TeamsChannelAccount + { + Id = account.Id, + Name = account.Name, + AadObjectId = account.AadObjectId, + Email = account.Email, + GivenName = account.GivenName, + Surname = account.Surname, + UserPrincipalName = account.UserPrincipalName, + UserRole = account.UserRole, + TenantId = account.TenantId + }; +} +``` + +**Strategy 2: JSON Round-Trip** (for complex models) +```csharp +public static TeamDetails ToCompatTeamDetails(this Apps.TeamDetails teamDetails) +{ + var json = System.Text.Json.JsonSerializer.Serialize(teamDetails); + return Newtonsoft.Json.JsonConvert.DeserializeObject(json)!; +} +``` + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `CompatAdapter` | Main adapter implementing Bot Framework interface | Adapter | +| `CompatBotAdapter` | Base adapter for turn context creation | Adapter | +| `CompatConnectorClient` | Wraps connector client functionality | Wrapper | +| `CompatConversations` | Wraps conversation operations | Wrapper | +| `CompatUserTokenClient` | Wraps token client functionality | Wrapper | +| `CompatAdapterMiddleware` | Bridges middleware systems | Bridge | +| `CompatTeamsInfo` | Static helper methods for Teams operations | Static Helper | +| `CompatActivity` | Extension methods for model conversion | Extension Methods | + +### Migration Path + +```mermaid +graph LR + subgraph Phase1["Phase 1: Drop-in Replacement"] + BFBot1[Existing Bot Framework Bot] + AddCompat1[services.AddCompatAdapter] + BFBot1 --> AddCompat1 + end + + subgraph Phase2["Phase 2: Incremental Migration"] + BFBot2[Mixed Usage] + UseCore[Use Core SDK for new features] + KeepBF[Keep BF code for existing] + BFBot2 --> UseCore + BFBot2 --> KeepBF + end + + subgraph Phase3["Phase 3: Full Migration"] + CoreBot[Pure Teams SDK Bot] + TeamsBotApp[TeamsBotApplication] + Handlers[Typed Handlers] + CoreBot --> TeamsBotApp + CoreBot --> Handlers + end + + AddCompat1 -.Next Phase.-> BFBot2 + KeepBF -.Next Phase.-> CoreBot + + style Phase1 fill:#ff3b30 + style Phase2 fill:#ff9500 + style Phase3 fill:#34c759 +``` + +### Configuration + +```csharp +services.AddCompatAdapter(configuration); +// Registers everything from Apps plus: +// - CompatAdapter as IBotFrameworkHttpAdapter (Singleton) +// - CompatBotAdapter (Singleton) +``` + +--- + +## Cross-Cutting Patterns + +### 1. **Dependency Injection Pattern** + +All three projects use ASP.NET Core DI extensively. + +```mermaid +graph TB + subgraph "DI Container" + Services[IServiceCollection] + end + + subgraph "Core Registrations" + BotApp[BotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + HttpClient[BotHttpClient] + end + + subgraph "Apps Registrations" + TeamsBotApp[TeamsBotApplication] + TeamsAPI[TeamsApiClient] + HttpCtx[IHttpContextAccessor] + end + + subgraph "Compat Registrations" + Adapter[CompatAdapter] + BotAdapter[CompatBotAdapter] + end + + Services --> BotApp + Services --> ConvClient + Services --> TokenClient + Services --> HttpClient + Services --> TeamsBotApp + Services --> TeamsAPI + Services --> HttpCtx + Services --> Adapter + Services --> BotAdapter + + TeamsBotApp -.extends.-> BotApp + Adapter -.uses.-> TeamsBotApp +``` + +### 2. **Configuration Pattern** + +Hierarchical configuration with conventions. + +```csharp +{ + "AzureAd": { + "ClientId": "...", + "TenantId": "...", + "ClientSecret": "..." + }, + "MicrosoftAppId": "...", + "MicrosoftAppPassword": "...", + "MicrosoftAppType": "MultiTenant" +} +``` + +**Configuration Precedence**: +1. Environment variables +2. appsettings.json +3. Configuration section (AzureAd, etc.) +4. Fallback defaults + +### 3. **Authentication Pattern** + +JWT bearer token authentication for API calls. + +```mermaid +sequenceDiagram + participant Client as Bot Client + participant Auth as BotAuthenticationHandler + participant AAD as Azure AD + participant API as Teams API + + Client->>Auth: Request with credentials + Auth->>AAD: Get access token + AAD-->>Auth: JWT token + Auth->>Auth: Add Authorization header + Auth->>API: Request with Bearer token + API-->>Auth: Response + Auth-->>Client: Response +``` + +### 4. **Error Handling Pattern** + +Structured exception handling with custom exceptions. + +```csharp +public class BotHandlerException : Exception +{ + public CoreActivity Activity { get; } + public BotHandlerException(CoreActivity activity, string message, Exception? innerException) + : base(message, innerException) + { + Activity = activity; + } +} +``` + +### 5. **Logging Pattern** + +Structured logging with scopes and log levels. + +```csharp +using (_logger.BeginScope("Processing activity {Type} {Id}", activity.Type, activity.Id)) +{ + _logger.LogInformation("Processing activity {Type}", activity.Type); + _logger.LogTrace("Activity details: {Activity}", activity.ToJson()); +} +``` + +--- + +## Performance Considerations + +### 1. **System.Text.Json Source Generation** + +- **Core SDK**: Uses source-generated JSON serializers for zero-allocation deserialization +- **AOT Ready**: Supports ahead-of-time compilation +- **Performance**: 2-3x faster than reflection-based serialization + +### 2. **Object Pooling** + +- Reuses objects where possible to reduce GC pressure +- Particularly important for high-throughput scenarios + +### 3. **Async/Await Best Practices** + +- ConfigureAwait(false) used throughout to avoid context switching +- Cancellation token support for graceful shutdown +- ValueTask for hot paths where appropriate + +### 4. **Minimal Allocations** + +- Uses Span and Memory where applicable +- Avoids unnecessary string allocations +- Lazy initialization of expensive resources + +--- + +## Testing Strategy + +```mermaid +graph TB + subgraph "Test Levels" + Unit[Unit Tests] + Integration[Integration Tests] + E2E[End-to-End Tests] + end + + subgraph "Test Projects" + CoreTests[Microsoft.Teams.Bot.Core.UnitTests] + AppsTests[Microsoft.Teams.Bot.Apps.UnitTests] + CompatTests[Microsoft.Teams.Bot.Compat.UnitTests] + IntTests[Microsoft.Teams.Bot.Core.Tests] + end + + Unit --> CoreTests + Unit --> AppsTests + Unit --> CompatTests + + Integration --> IntTests + + style Unit fill:#34c759 + style Integration fill:#ff9500 + style E2E fill:#ff3b30 +``` + +### Test Patterns + +1. **Unit Tests**: Mock dependencies, test in isolation +2. **Integration Tests**: Test with live services (requires credentials) +3. **Compatibility Tests**: Verify Bot Framework v4 compatibility + +--- + +## Summary + +### Design Principles + +1. **Separation of Concerns**: Clear layering with distinct responsibilities +2. **Dependency Inversion**: Depend on abstractions, not implementations +3. **Single Responsibility**: Each class has one reason to change +4. **Open/Closed**: Open for extension, closed for modification +5. **Performance First**: Optimized for high-throughput scenarios +6. **Backward Compatibility**: Smooth migration path from Bot Framework v4 + +### Key Takeaways + +| Layer | Primary Pattern | Main Benefit | +|-------|----------------|--------------| +| **Core** | Middleware Pipeline | Extensible activity processing | +| **Apps** | Handler Pattern | Type-safe Teams-specific routing | +| **Compat** | Adapter Pattern | Seamless migration from Bot Framework v4 | + +### Evolution Path + +```mermaid +timeline + title SDK Evolution + Phase 1 (Current) : Bot Framework v4 with Compat layer + Phase 2 (Transition) : Mixed usage - Core for new features + Phase 3 (Target) : Pure Teams SDK with typed handlers + Phase 4 (Future) : Cloud-native with additional performance optimizations +``` diff --git a/core/docs/CompatTeamsInfo-API-Mapping.md b/core/docs/CompatTeamsInfo-API-Mapping.md new file mode 100644 index 00000000..51301610 --- /dev/null +++ b/core/docs/CompatTeamsInfo-API-Mapping.md @@ -0,0 +1,199 @@ +# CompatTeamsInfo API Mapping + +This document provides a comprehensive mapping of Bot Framework TeamsInfo static methods to their corresponding REST API endpoints and the Teams Bot Core SDK client implementations. + +## Overview + +The `CompatTeamsInfo` class provides a compatibility layer that adapts the Bot Framework v4 SDK TeamsInfo API to use the Teams Bot Core SDK. It implements 19 static methods organized into four functional categories. + +## API Method Mappings + +### Member & Participant Methods + +| Method | REST Endpoint | Client | Description | +|--------|--------------|--------|-------------| +| `GetMemberAsync` | `GET /v3/conversations/{conversationId}/members/{userId}` | ConversationClient | Gets a single conversation member by user ID | +| `GetMembersAsync` ⚠️ | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Gets all conversation members (deprecated - use paged version) | +| `GetPagedMembersAsync` | `GET /v3/conversations/{conversationId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of conversation members | +| `GetTeamMemberAsync` | `GET /v3/conversations/{teamId}/members/{userId}` | ConversationClient | Gets a single team member by user ID | +| `GetTeamMembersAsync` ⚠️ | `GET /v3/conversations/{teamId}/members` | ConversationClient | Gets all team members (deprecated - use paged version) | +| `GetPagedTeamMembersAsync` | `GET /v3/conversations/{teamId}/pagedmembers?pageSize={pageSize}&continuationToken={token}` | ConversationClient | Gets paginated list of team members | + +⚠️ *Deprecated by Microsoft Teams - use paged versions instead* + +### Meeting Methods + +| Method | REST Endpoint | Client | Description | +|--------|--------------|--------|-------------| +| `GetMeetingInfoAsync` | `GET /v1/meetings/{meetingId}` | TeamsApiClient | Gets meeting information by meeting ID | +| `GetMeetingParticipantAsync` | `GET /v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | TeamsApiClient | Gets a specific meeting participant's information | +| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | TeamsApiClient | Sends an in-meeting notification to participants | + +### Team & Channel Methods + +| Method | REST Endpoint | Client | Description | +|--------|--------------|--------|-------------| +| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | TeamsApiClient | Gets detailed information about a team | +| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/channels` | TeamsApiClient | Gets list of channels in a team | + +### Batch Messaging Methods + +| Method | REST Endpoint | Client | Description | +|--------|--------------|--------|-------------| +| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | TeamsApiClient | Sends a message to a list of users | +| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | TeamsApiClient | Sends a message to a list of channels | +| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | TeamsApiClient | Sends a message to all users in a team | +| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | TeamsApiClient | Sends a message to all users in a tenant | +| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Creates a conversation in a Teams channel and sends a message | + +### Batch Operation Management Methods + +| Method | REST Endpoint | Client | Description | +|--------|--------------|--------|-------------| +| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | TeamsApiClient | Gets the state of a batch operation | +| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken={token}` | TeamsApiClient | Gets failed entries from a batch operation | +| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | TeamsApiClient | Cancels a batch operation | + +## Client Distribution + +The implementation uses two primary clients from the Teams Bot Core SDK: + +### ConversationClient (6 methods) +Used for member and participant operations in conversations and teams. Accessed via the `IConnectorClient` in TurnState. + +**Methods:** +- GetMemberAsync +- GetMembersAsync +- GetPagedMembersAsync +- GetTeamMemberAsync +- GetTeamMembersAsync +- GetPagedTeamMembersAsync + +### TeamsApiClient (12 methods) +Used for Teams-specific operations including meetings, team details, channels, and batch messaging. Added to TurnState by the CompatAdapter. + +**Methods:** +- GetMeetingInfoAsync +- GetMeetingParticipantAsync +- SendMeetingNotificationAsync +- GetTeamDetailsAsync +- GetTeamChannelsAsync +- SendMessageToListOfUsersAsync +- SendMessageToListOfChannelsAsync +- SendMessageToAllUsersInTeamAsync +- SendMessageToAllUsersInTenantAsync +- GetOperationStateAsync +- GetPagedFailedEntriesAsync +- CancelOperationAsync + +### Bot Framework Adapter (1 method) +One method uses the Bot Framework adapter directly for backward compatibility. + +**Methods:** +- SendMessageToTeamsChannelAsync + +## Implementation Details + +### Model Conversion Strategy + +The implementation uses two strategies for converting between Bot Framework and Core SDK models: + +1. **Direct Property Mapping**: For simple models like `TeamsChannelAccount`, `ChannelInfo`, etc. +2. **JSON Round-Trip**: For complex models like `TeamDetails`, `MeetingNotificationResponse`, `BatchOperationState`, etc. + +### Type Conversions + +Key extension methods in `CompatActivity.cs`: + +| Extension Method | Source Type | Target Type | Strategy | +|------------------|-------------|-------------|----------| +| `ToCompatTeamsChannelAccount` | Core TeamsConversationAccount | BF TeamsChannelAccount | Direct mapping | +| `ToCompatMeetingInfo` | Core MeetingInfo | BF MeetingInfo | Direct mapping | +| `ToCompatTeamsMeetingParticipant` | Core MeetingParticipant | BF TeamsMeetingParticipant | Direct mapping | +| `ToCompatChannelInfo` | Core Channel | BF ChannelInfo | Direct mapping | +| `ToCompatTeamsPagedMembersResult` | Core PagedMembersResult | BF TeamsPagedMembersResult | Direct mapping | +| `ToCompatTeamDetails` | Core TeamDetails | BF TeamDetails | JSON round-trip | +| `ToCompatMeetingNotificationResponse` | Core MeetingNotificationResponse | BF MeetingNotificationResponse | JSON round-trip | +| `ToCompatBatchOperationState` | Core BatchOperationState | BF BatchOperationState | JSON round-trip | +| `ToCompatBatchFailedEntriesResponse` | Core BatchFailedEntriesResponse | BF BatchFailedEntriesResponse | JSON round-trip | +| `FromCompatTeamMember` | BF TeamMember | Core TeamMember | JSON round-trip | + +### Authentication + +All methods use `AgenticIdentity` extracted from the turn context activity properties for authentication with the Teams services. + +### Service URL + +All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`), which points to the appropriate Teams channel service endpoint. + +## Usage Examples + +### Getting a Team Member + +```csharp +var member = await TeamsInfo.GetMemberAsync(turnContext, userId, cancellationToken); +Console.WriteLine($"Member: {member.Name} ({member.Email})"); +``` + +### Getting Meeting Information + +```csharp +var meetingInfo = await TeamsInfo.GetMeetingInfoAsync(turnContext, meetingId, cancellationToken); +Console.WriteLine($"Meeting: {meetingInfo.Details.Title}"); +``` + +### Sending a Batch Message + +```csharp +var activity = MessageFactory.Text("Hello from bot!"); +var members = new List { new TeamMember(userId1), new TeamMember(userId2) }; +var operationId = await TeamsInfo.SendMessageToListOfUsersAsync( + turnContext, activity, members, tenantId, cancellationToken); + +// Check operation status +var state = await TeamsInfo.GetOperationStateAsync(turnContext, operationId, cancellationToken); +Console.WriteLine($"Operation state: {state.State}"); +``` + +### Getting Team Channels + +```csharp +var channels = await TeamsInfo.GetTeamChannelsAsync(turnContext, teamId, cancellationToken); +foreach (var channel in channels) +{ + Console.WriteLine($"Channel: {channel.Name} ({channel.Id})"); +} +``` + +## Testing + +Comprehensive integration tests are available in `test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs`. All tests are marked with `[Fact(Skip = "Requires live service credentials")]` and require environment variables to be set for live testing: + +- `TEST_USER_ID` +- `TEST_CONVERSATIONID` +- `TEST_TEAMID` +- `TEST_CHANNELID` +- `TEST_MEETINGID` +- `TEST_TENANTID` + +## Modified Core Models + +To support full compatibility, the following Core SDK models were enhanced: + +### TeamsConversationAccount +Added properties to match Bot Framework `TeamsChannelAccount`: +- `GivenName` +- `Surname` +- `Email` +- `UserPrincipalName` +- `UserRole` +- `TenantId` + +### MeetingInfo +Changed `Organizer` property type from `ConversationAccount` to `TeamsConversationAccount` to match Bot Framework schema. + +## References + +- [Bot Framework TeamsInfo Source](https://github.com/microsoft/botbuilder-dotnet/blob/main/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs) +- [Teams REST API Documentation](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +- [Teams Meeting Notifications](https://docs.microsoft.com/en-us/microsoftteams/platform/apps-in-teams-meetings/meeting-apps-apis) diff --git a/core/samples/AFBot/AFBot.csproj b/core/samples/AFBot/AFBot.csproj new file mode 100644 index 00000000..cfd5f10d --- /dev/null +++ b/core/samples/AFBot/AFBot.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/core/samples/AFBot/DropTypingMiddleware.cs b/core/samples/AFBot/DropTypingMiddleware.cs new file mode 100644 index 00000000..92c7a172 --- /dev/null +++ b/core/samples/AFBot/DropTypingMiddleware.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace AFBot; + +internal class DropTypingMiddleware : ITurnMiddleware +{ + public Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default) + { + if (activity.Type == ActivityType.Typing) return Task.CompletedTask; + return nextTurn(cancellationToken); + } +} diff --git a/core/samples/AFBot/Program.cs b/core/samples/AFBot/Program.cs new file mode 100644 index 00000000..3913af59 --- /dev/null +++ b/core/samples/AFBot/Program.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using AFBot; +using Azure.AI.OpenAI; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using OpenAI; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddOpenTelemetry().UseAzureMonitor(); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); +BotApplication botApp = webApp.UseBotApplication(); + +AzureOpenAIClient azureClient = new( + new Uri("https://tsdkfoundry.openai.azure.com/"), + new ApiKeyCredential(Environment.GetEnvironmentVariable("AZURE_OpenAI_KEY")!)); + +ChatClientAgent agent = azureClient.GetChatClient("gpt-5-nano").CreateAIAgent( + instructions: "You are an expert acronym maker, made an acronym made up from the first three characters of the user's message. " + + "Some examples: OMW on my way, BTW by the way, TVM thanks very much, and so on." + + "Always respond with the three complete words only, and include a related emoji at the end.", + name: "AcronymMaker"); + +botApp.UseMiddleware(new DropTypingMiddleware()); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(activity); + + CancellationTokenSource timer = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, new CancellationTokenSource(TimeSpan.FromSeconds(15)).Token); + + CoreActivity typing = CoreActivity.CreateBuilder() + .WithType(ActivityType.Typing) + .WithConversationReference(activity) + .Build(); + await botApp.SendActivityAsync(typing, cancellationToken); + + AgentRunResponse agentResponse = await agent.RunAsync(activity.Properties["text"]?.ToString() ?? "OMW", cancellationToken: timer.Token); + + ChatMessage? m1 = agentResponse.Messages.FirstOrDefault(); + Console.WriteLine($"AI:: GOT {agentResponse.Messages.Count} msgs"); + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", m1!.Text) + .Build(); + + SendActivityResponse? res = await botApp.SendActivityAsync(replyActivity, cancellationToken); + + Console.WriteLine("SENT >>> => " + res?.Id); +}; + +webApp.Run(); diff --git a/core/samples/AFBot/appsettings.json b/core/samples/AFBot/appsettings.json new file mode 100644 index 00000000..1ff8c135 --- /dev/null +++ b/core/samples/AFBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/AllFeatures/AllFeatures.csproj b/core/samples/AllFeatures/AllFeatures.csproj new file mode 100644 index 00000000..515a66f8 --- /dev/null +++ b/core/samples/AllFeatures/AllFeatures.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/AllFeatures/AllFeatures.http b/core/samples/AllFeatures/AllFeatures.http new file mode 100644 index 00000000..e81f1fd6 --- /dev/null +++ b/core/samples/AllFeatures/AllFeatures.http @@ -0,0 +1,6 @@ +@AllFeatures_HostAddress = http://localhost:5290 + +GET {{AllFeatures_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/core/samples/AllFeatures/Program.cs b/core/samples/AllFeatures/Program.cs new file mode 100644 index 00000000..ff314f19 --- /dev/null +++ b/core/samples/AllFeatures/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + await context.SendTypingActivityAsync(cancellationToken); + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithConversationReference(context.Activity) + .WithText(replyText) + .Build(); + + reply.AddMention(context.Activity.From!, "ridobotlocal", true); + + await context.TeamsBotApplication.SendActivityAsync(reply, cancellationToken); +}; + +teamsApp.Run(); diff --git a/core/samples/AllFeatures/appsettings.json b/core/samples/AllFeatures/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/core/samples/AllFeatures/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/AllInvokesBot/AllInvokesBot.csproj b/core/samples/AllInvokesBot/AllInvokesBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/AllInvokesBot/AllInvokesBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/AllInvokesBot/Cards.cs b/core/samples/AllInvokesBot/Cards.cs new file mode 100644 index 00000000..6a9c6f31 --- /dev/null +++ b/core/samples/AllInvokesBot/Cards.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace AllInvokesBot; + +public static class Cards +{ + public static JsonObject CreateWelcomeCard() + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Welcome to InvokesBot!", + ["size"] = "Large", + ["weight"] = "Bolder" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Click the buttons below to test different invoke handlers:" + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Execute", + ["id"] = "1234", + ["title"] = "Test Adaptive Card Action", + ["verb"] = "testAction", + ["data"] = new JsonObject + { + ["message"] = "Button clicked!" + } + }, + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Open Task Module", + ["data"] = new JsonObject + { + ["msteams"] = new JsonObject + { + ["type"] = "task/fetch" + } + } + }, + new JsonObject + { + ["type"] = "Action.Execute", + ["title"] = "Request File Upload", + ["verb"] = "requestFileUpload" + } + } + }; + } + + public static JsonObject CreateFileConsentCard() + { + return new JsonObject + { + ["description"] = "This is a sample file to demonstrate file consent", + ["sizeInBytes"] = 1024, + ["acceptContext"] = new JsonObject + { + ["fileId"] = "123456" + }, + ["declineContext"] = new JsonObject + { + ["fileId"] = "123456" + } + }; + } + + public static JsonObject CreateAdaptiveActionResponseCard(string? verb, string? message) + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Action '{verb}' executed", + ["weight"] = "Bolder" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Message: {message}", + ["wrap"] = true + } + } + }; + } + + public static JsonObject CreateTaskModuleCard() + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Task Module" + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Submit" + } + } + }; + } + + public static JsonObject CreateFileInfoCard(string? uniqueId, string? fileType) + { + return new JsonObject + { + ["uniqueId"] = uniqueId, + ["fileType"] = fileType + }; + } +} diff --git a/core/samples/AllInvokesBot/Program.cs b/core/samples/AllInvokesBot/Program.cs new file mode 100644 index 00000000..064b6218 --- /dev/null +++ b/core/samples/AllInvokesBot/Program.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using AllInvokesBot; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Hosting; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseBotApplication(); + +// ==================== MESSAGE - SEND SIMPLE CARD ==================== +bot.OnMessage(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnMessage"); + + JsonObject card = Cards.CreateWelcomeCard(); + + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(card) + .Build(); + + await context.SendActivityAsync(new MessageActivity([attachment]), cancellationToken); +}); + +// ==================== ADAPTIVE CARD ACTION ==================== +bot.OnAdaptiveCardAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnAdaptiveCardAction"); + AdaptiveCardActionValue? value = context.Activity.Value; + AdaptiveCardAction? action = value?.Action; + string? verb = action?.Verb; + Dictionary? data = action?.Data; + + Console.WriteLine($" Verb: {verb}"); + Console.WriteLine($" Data: {JsonSerializer.Serialize(data)}"); + + // Handle file upload request + if (verb == "requestFileUpload") + { + JsonObject fileConsentCard = Cards.CreateFileConsentCard(); + TeamsAttachment fileConsentCardResponse = TeamsAttachment.CreateBuilder() + .WithContent(fileConsentCard).WithContentType(AttachmentContentType.FileConsentCard) + .WithName("file_consent.json").Build(); + await context.SendActivityAsync(new MessageActivity([fileConsentCardResponse]), cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("File Consent requested!"); + } + + string? message = data != null && data.TryGetValue("message", out object? msgValue) ? msgValue?.ToString() : null; + + JsonObject adaptiveActionCard = Cards.CreateAdaptiveActionResponseCard(verb, message); + TeamsAttachment adaptiveActionCardResponse = TeamsAttachment.CreateBuilder().WithAdaptiveCard(adaptiveActionCard).Build(); + await context.SendActivityAsync(new MessageActivity([adaptiveActionCardResponse]), cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("Action submitted!"); +}); + +// ==================== TASK MODULE - FETCH ==================== +bot.OnTaskFetch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnTaskFetch"); + TeamsAttachment taskModuleCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.CreateTaskModuleCard()).Build(); + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Task") + .WithHeight("medium") + .WithWidth("medium") + .WithCard(taskModuleCardResponse) + .Build(); + +}); + +// ==================== TASK MODULE - SUBMIT ==================== +bot.OnTaskSubmit(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnTaskSubmit"); + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Message) + .WithMessage("Done") + .Build(); +}); + +// ==================== FILE CONSENT ==================== +bot.OnFileConsent(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnFileConsent"); + + FileConsentValue? value = context.Activity.Value; + string? action = value?.Action; + FileUploadInfo? uploadInfo = value?.UploadInfo; + object? consentContext = value?.Context; + + if (action == "accept") + { + Console.WriteLine($" File accepted!"); + + // Upload the file + string? uploadUrl = uploadInfo?.UploadUrl?.ToString(); + string? fileName = uploadInfo?.Name; + string? contentUrl = uploadInfo?.ContentUrl?.ToString(); + string? uniqueId = uploadInfo?.UniqueId; + + if (uploadUrl != null && contentUrl != null) + { + // Create sample file content + string fileContent = "This is a sample file uploaded via file consent!"; + byte[] fileBytes = System.Text.Encoding.UTF8.GetBytes(fileContent); + int fileSize = fileBytes.Length; + + using HttpClient httpClient = new(); + using ByteArrayContent content = new(fileBytes); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentRange = new System.Net.Http.Headers.ContentRangeHeaderValue(0, fileSize - 1, fileSize); + + try + { + HttpResponseMessage uploadResponse = await httpClient.PutAsync(uploadUrl, content, cancellationToken); + Console.WriteLine($" Upload Status: {uploadResponse.StatusCode}"); + + if (uploadResponse.IsSuccessStatusCode) + { + JsonObject fileInfoContent = Cards.CreateFileInfoCard(uniqueId, uploadInfo?.FileType); + + TeamsAttachment fileUploadResponse = TeamsAttachment.CreateBuilder() + .WithName(fileName) + .WithContentType(AttachmentContentType.FileInfoCard) + .WithContentUrl(contentUrl != null ? new Uri(contentUrl) : null) + .WithContent(fileInfoContent).Build(); + + await context.SendActivityAsync(new MessageActivity([fileUploadResponse]), cancellationToken); + } + else + { + Console.WriteLine($" File upload failed: {await uploadResponse.Content.ReadAsStringAsync(cancellationToken)}"); + } + } + catch (Exception ex) + { + Console.WriteLine($" File upload error: {ex.Message}"); + } + } + } + else if (action == "decline") + { + Console.WriteLine($" File declined!"); + Console.WriteLine($" Context: {JsonSerializer.Serialize(consentContext)}"); + } + + return AdaptiveCardResponse.CreateBuilder() + .WithStatusCode(200) + .Build(); +}); + +/* +// ==================== EXECUTE ACTION ==================== +bot.OnExecuteAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnExecuteAction"); + + var responseBody = new JsonObject + { + ["status"] = "completed" + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== HANDOFF ==================== +bot.OnHandoff(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnHandoff"); + return new CoreInvokeResponse(200); +}); + +// ==================== SEARCH ==================== +bot.OnSearch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSearch"); + + var responseBody = new JsonObject + { + ["results"] = new JsonArray + { + new JsonObject + { + ["id"] = "1", + ["title"] = "Result" + } + } + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== MESSAGE SUBMIT ACTION ==================== +bot.OnMessageSubmitAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnMessageSubmitAction"); + + var data = context.Activity.Value; + Console.WriteLine($" Data: {System.Text.Json.JsonSerializer.Serialize(data)}"); + + // Extract data fields + var jsonData = System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(data)); + + string? action = jsonData.TryGetProperty("action", out var a) ? a.GetString() : "unknown"; + string? value = jsonData.TryGetProperty("value", out var v) ? v.GetString() : "no value"; + + Console.WriteLine($" Action: {action}"); + Console.WriteLine($" Value: {value}"); + + var responseBody = new JsonObject + { + ["statusCode"] = 200, + ["type"] = "application/vnd.microsoft.activity.message", + ["value"] = $"Message action '{action}' submitted! Value: {value}" + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== CONFIG FETCH ==================== +bot.OnConfigFetch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnConfigFetch"); + + var card = new + { + contentType = AttachmentContentType.AdaptiveCard, + content = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = "Extension Settings", size = "large", weight = "bolder" }, + new { type = "TextBlock", text = "Configure your messaging extension settings below:", wrap = true }, + new { type = "Input.Text", id = "apiKey", label = "API Key", placeholder = "Enter your API key" }, + new { type = "Input.Toggle", id = "enableNotifications", label = "Enable Notifications", value = "true" } + }, + actions = new object[] + { + new { type = "Action.Submit", title = "Save Settings" } + } + } + }; + + var response = TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Configure Messaging Extension") + .WithHeight(TaskModuleSize.Medium) + .WithWidth(TaskModuleSize.Medium) + .WithCard(card) + .Build(); + + return new CoreInvokeResponse(200, response); +}); + +// ==================== CONFIG SUBMIT ==================== +bot.OnConfigSubmit(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnConfigSubmit"); + + var data = context.Activity.Value; + Console.WriteLine($" Config data: {System.Text.Json.JsonSerializer.Serialize(data)}"); + + // In a real app, you would save these settings to a database + // associated with the user/team + + var response = TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Message) + .WithMessage("Settings saved successfully!") + .Build(); + + return new CoreInvokeResponse(200, response); +}); +*/ + +webApp.Run(); diff --git a/core/samples/AllInvokesBot/README.md b/core/samples/AllInvokesBot/README.md new file mode 100644 index 00000000..bcb0805e --- /dev/null +++ b/core/samples/AllInvokesBot/README.md @@ -0,0 +1,52 @@ +# AllInvokesBot Testing Guide + +A sample bot demonstrating Teams invoke handlers. + +## Setup + +1. Configure bot credentials in `appsettings.json` or environment variables +2. Run the bot: `dotnet run` +3. Upload `manifest.json` to Teams + +## Testing Handlers + +### OnMessage +**Manifest:** `bots` section with appropriate `scopes` (personal, team, groupChat) + +1. Send any message to the bot in 1:1 chat +2. Verify welcome card with action buttons appears + +### OnAdaptiveCardAction +**Manifest:** No specific requirement (triggered by adaptive card actions) + +1. After receiving the welcome card +2. Click any action button on the card +3. Verify action response card appears +4. Console logs will show the verb and data + +**File Upload Flow:** +1. Click "Request File Upload" button +2. Verify file consent card appears + +### OnFileConsent +**Manifest:** `bots.supportsFiles: true` +**Azure:** Delegated permission `Files.ReadWrite.All` required in Azure app registration + +1. After requesting file upload (see above) +2. Click Accept or Decline on the file consent card +3. If Accept - verify file uploads and file info card appears +4. If Decline - verify console logs the decline action + +### OnTaskFetch +**Manifest:** No specific requirement (triggered by task module actions) + +1. Click "Open Task Module" button on the welcome card +2. Verify task module dialog opens with input form + +### OnTaskSubmit +**Manifest:** No specific requirement (works with OnTaskFetch) + +1. Open task module (see OnTaskFetch) +2. Fill in the form +3. Click submit +4. Verify "Done" message appears diff --git a/core/samples/AllInvokesBot/appsettings.json b/core/samples/AllInvokesBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/AllInvokesBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/AllInvokesBot/manifest.json b/core/samples/AllInvokesBot/manifest.json new file mode 100644 index 00000000..53406f9e --- /dev/null +++ b/core/samples/AllInvokesBot/manifest.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "1.24", + "id": "YOUR_BOT_ID", + "name": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://microsoft.com", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/legal/terms-of-use" + }, + "description": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": [ + "personal" + ] + }, + { + "entityId": "about", + "scopes": [ + "personal" + ] + } + ], + "bots": [ + { + "botId": "YOUR_BOT_ID", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": true + } + ], + "validDomains": [ + "*.microsoft.com" + ], + "webApplicationInfo": { + "id": "YOUR_BOT_ID", + "resource": "https://graph.microsoft.com" + } +} diff --git a/core/samples/CompatBot/Cards.cs b/core/samples/CompatBot/Cards.cs new file mode 100644 index 00000000..4ba0be86 --- /dev/null +++ b/core/samples/CompatBot/Cards.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CompatBot; + +internal class Cards +{ + + public static object ResponseCard(string? feedback) => new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Form Submitted Successfully! ✓", + weight = "Bolder", + size = "Large", + color = "Good" + }, + new + { + type = "TextBlock", + text = $"You entered: **{feedback ?? "(empty)"}**", + wrap = true + } + } + }; + + public static readonly object FeedbackCardObj = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "Please provide your feedback:", + weight = "Bolder", + size = "Medium" + }, + new + { + type = "Input.Text", + id = "feedback", + placeholder = "Enter your feedback here", + isMultiline = true + } + }, + actions = new object[] + { + new + { + type = "Action.Execute", + title = "Submit Feedback" + } + } + }; +} diff --git a/core/samples/CompatBot/CompatBot.csproj b/core/samples/CompatBot/CompatBot.csproj new file mode 100644 index 00000000..abacede3 --- /dev/null +++ b/core/samples/CompatBot/CompatBot.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs new file mode 100644 index 00000000..1d68f2c0 --- /dev/null +++ b/core/samples/CompatBot/EchoBot.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Newtonsoft.Json.Linq; + +namespace CompatBot; + +public class ConversationData +{ + public int MessageCount { get; set; } = 0; + +} + +internal class EchoBot(TeamsBotApplication teamsBotApp, ConversationState conversationState, ILogger logger) + : TeamsActivityHandler +{ + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + await base.OnTurnAsync(turnContext, cancellationToken); + + await conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("OnMessage"); + IStatePropertyAccessor conversationStateAccessors = conversationState.CreateProperty(nameof(ConversationData)); + ConversationData conversationData = await conversationStateAccessors.GetAsync(turnContext, () => new ConversationData(), cancellationToken); + + string replyText = $"Echo from BF Compat [{conversationData.MessageCount++}]: {turnContext.Activity.Text}"; + await turnContext.SendActivityAsync(MessageFactory.Text(replyText, replyText), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive message `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + + // TeamsAPXClient provides Teams-specific operations like: + // - FetchTeamDetailsAsync, FetchChannelListAsync + // - FetchMeetingInfoAsync, FetchParticipantAsync, SendMeetingNotificationAsync + // - Batch messaging: SendMessageToListOfUsersAsync, SendMessageToAllUsersInTenantAsync, etc. + + await SendUpdateDeleteActivityAsync(turnContext, teamsBotApp.ConversationClient, cancellationToken); + + Attachment attachment = new() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.FeedbackCardObj + }; + IMessageActivity attachmentReply = MessageFactory.Attachment(attachment); + await turnContext.SendActivityAsync(attachmentReply, cancellationToken); + + } + + + protected override async Task OnMessageReactionActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Message reaction received."), cancellationToken); + } + + protected override async Task OnInstallationUpdateActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Installation update received."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override async Task OnInstallationUpdateAddAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Installation update Add received."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override async Task OnInvokeActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("Invoke Activity received: {Name}", turnContext.Activity.Name); + JObject actionValue = JObject.FromObject(turnContext.Activity.Value); + JObject? action = actionValue["action"] as JObject; + JObject? actionData = action?["data"] as JObject; + string? userInput = actionData?["feedback"]?.ToString(); + //var userInput = actionValue["userInput"]?.ToString(); + + logger.LogInformation("Action: {Action}, User Input: {UserInput}", action, userInput); + + + + Attachment attachment = new() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.ResponseCard(userInput) + }; + + IMessageActivity card = MessageFactory.Attachment(attachment); + await turnContext.SendActivityAsync(card, cancellationToken); + + return new Microsoft.Bot.Builder.InvokeResponse + { + Status = 200, + Body = new { value = "invokes from compat bot" } + }; + } + + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Welcome."), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"Send a proactive messages to `/api/notify/{turnContext.Activity.Conversation.Id}`"), cancellationToken); + } + + protected override Task OnMembersRemovedAsync(IList membersRemoved, ITurnContext turnContext, CancellationToken cancellationToken) + { + return turnContext.SendActivityAsync(MessageFactory.Text("Bye."), cancellationToken); + } + + protected override async Task OnTeamsMeetingStartAsync(MeetingStartEventDetails meeting, ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Welcome to meeting: "), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text($"{meeting.Title} {meeting.MeetingType}"), cancellationToken); + } + + private static async Task SendUpdateDeleteActivityAsync(ITurnContext turnContext, ConversationClient conversationClient, CancellationToken cancellationToken) + { + ConversationReference cr = turnContext.Activity.GetConversationReference(); + Activity reply = (Activity)Activity.CreateMessageActivity(); + reply.ApplyConversationReference(cr, isIncoming: false); + reply.Text = "This is a proactive message sent using the Conversations API."; + + CoreActivity ca = reply.FromCompatActivity(); + + SendActivityResponse res = await conversationClient.SendActivityAsync(ca, null, cancellationToken); + + await Task.Delay(2000, cancellationToken); + + await conversationClient.UpdateActivityAsync( + cr.Conversation.Id, + res.Id!, + TeamsActivity.CreateBuilder() + .WithId(res.Id ?? "") + .WithServiceUrl(new Uri(turnContext.Activity.ServiceUrl)) + .WithType(ActivityType.Message) + .WithText("This message has been updated.") + .WithFrom(ca.From) + .Build(), + null, + cancellationToken); + + await Task.Delay(2000, cancellationToken); + + await conversationClient.DeleteActivityAsync(cr.Conversation.Id, res.Id!, new Uri(turnContext.Activity.ServiceUrl), AgenticIdentity.FromProperties(ca.From?.Properties), null, cancellationToken); + + await turnContext.SendActivityAsync(MessageFactory.Text("Proactive message sent and deleted."), cancellationToken); + } + +} diff --git a/core/samples/CompatBot/MyCompatMiddleware.cs b/core/samples/CompatBot/MyCompatMiddleware.cs new file mode 100644 index 00000000..ef87a97e --- /dev/null +++ b/core/samples/CompatBot/MyCompatMiddleware.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; + +namespace CompatBot +{ + public class MyCompatMiddleware : Microsoft.Bot.Builder.IMiddleware + { + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + Console.WriteLine("MyCompatMiddleware: OnTurnAsync"); + Console.WriteLine(turnContext.Activity.Text); + + await turnContext.SendActivityAsync(MessageFactory.Text("Hello from MyCompatMiddleware!"), cancellationToken); + + await next(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/core/samples/CompatBot/Program.cs b/core/samples/CompatBot/Program.cs new file mode 100644 index 00000000..673bea7e --- /dev/null +++ b/core/samples/CompatBot/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Monitor.OpenTelemetry.AspNetCore; +using CompatBot; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; + +// using Microsoft.Bot.Connector.Authentication; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenTelemetry().UseAzureMonitor(); +builder.AddCompatAdapter(); + +//builder.Services.AddSingleton(); +//builder.Services.AddSingleton(provider => +// new CloudAdapter( +// provider.GetRequiredService(), +// provider.GetRequiredService>())); + + +MemoryStorage storage = new(); +ConversationState conversationState = new(storage); +builder.Services.AddSingleton(conversationState); +builder.Services.AddTransient(); + +WebApplication app = builder.Build(); + +CompatAdapter compatAdapter = (CompatAdapter)app.Services.GetRequiredService(); +compatAdapter.Use(new MyCompatMiddleware()); +compatAdapter.Use(new MyCompatMiddleware()); + +app.MapPost("/api/messages", async (IBotFrameworkHttpAdapter adapter, IBot bot, HttpRequest request, HttpResponse response, CancellationToken ct) => + await adapter.ProcessAsync(request, response, bot, ct)); + +app.MapGet("/api/notify/{cid}", async (IBotFrameworkHttpAdapter adapter, string cid, CancellationToken ct) => +{ + Activity proactive = new() + { + Conversation = new() { Id = cid }, + ServiceUrl = "https://smba.trafficmanager.net/teams" + }; + await ((BotAdapter)adapter).ContinueConversationAsync( + string.Empty, + proactive.GetConversationReference(), + async (turnContext, ct) => + { + await turnContext.SendActivityAsync( + MessageFactory.Text($"Proactive.
SDK `{BotApplication.Version}` at {DateTime.Now:T}"), ct); + }, + ct); +}); + +app.Run(); diff --git a/core/samples/CompatBot/appsettings.json b/core/samples/CompatBot/appsettings.json new file mode 100644 index 00000000..1ff8c135 --- /dev/null +++ b/core/samples/CompatBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/CompatProactive/CompatProactive.csproj b/core/samples/CompatProactive/CompatProactive.csproj new file mode 100644 index 00000000..e2d156f7 --- /dev/null +++ b/core/samples/CompatProactive/CompatProactive.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + false + enable + + + + + + + + + + + + diff --git a/core/samples/CompatProactive/ProactiveWorker.cs b/core/samples/CompatProactive/ProactiveWorker.cs new file mode 100644 index 00000000..0ba69924 --- /dev/null +++ b/core/samples/CompatProactive/ProactiveWorker.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Compat; + +namespace CompatProactive; + +internal class ProactiveWorker(IBotFrameworkHttpAdapter compatAdapter, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ConversationReference conversationReference = new() + { + ServiceUrl = "https://smba.trafficmanager.net/teams/", + Conversation = new() { Id = "19:ad37a1f8af5549e3b81edf249fe5cb1b@thread.tacv2" }, + }; + + await ((CompatAdapter)compatAdapter).ContinueConversationAsync("", conversationReference, callback, stoppingToken); + logger.LogInformation("Proactive message sent"); + } + + private async Task callback(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivitiesAsync(new Activity[] + { + MessageFactory.Text($"Proactive with Compat Layer {DateTimeOffset.Now}") + }, cancellationToken); + } +} diff --git a/core/samples/CompatProactive/Program.cs b/core/samples/CompatProactive/Program.cs new file mode 100644 index 00000000..440d3bad --- /dev/null +++ b/core/samples/CompatProactive/Program.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CompatProactive; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Bot.Compat; + + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddCompatAdapter(); +builder.Services.AddHostedService(); +IHost host = builder.Build(); +host.Run(); diff --git a/core/samples/CompatProactive/appsettings.json b/core/samples/CompatProactive/appsettings.json new file mode 100644 index 00000000..8a27e253 --- /dev/null +++ b/core/samples/CompatProactive/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Teams": "Trace" + } + } +} diff --git a/core/samples/CoreBot/CoreBot.csproj b/core/samples/CoreBot/CoreBot.csproj new file mode 100644 index 00000000..48aeee8f --- /dev/null +++ b/core/samples/CoreBot/CoreBot.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/CoreBot/Program.cs b/core/samples/CoreBot/Program.cs new file mode 100644 index 00000000..b3396f9e --- /dev/null +++ b/core/samples/CoreBot/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +webApp.MapGet("/", () => "CoreBot is running."); +BotApplication botApp = webApp.UseBotApplication(); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + string replyText = $"CoreBot running on SDK `{BotApplication.Version}`."; + + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", replyText) + .Build(); + + await botApp.SendActivityAsync(replyActivity, cancellationToken); +}; + +webApp.Run(); diff --git a/core/samples/CoreBot/appsettings.json b/core/samples/CoreBot/appsettings.json new file mode 100644 index 00000000..396e887e --- /dev/null +++ b/core/samples/CoreBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000;", + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/CustomHosting/CustomHosting.csproj b/core/samples/CustomHosting/CustomHosting.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/CustomHosting/CustomHosting.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/CustomHosting/MyTeamsBotApp.cs b/core/samples/CustomHosting/MyTeamsBotApp.cs new file mode 100644 index 00000000..05f39dec --- /dev/null +++ b/core/samples/CustomHosting/MyTeamsBotApp.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace CustomHosting; + +public class MyTeamsBotApp : TeamsBotApplication +{ + public MyTeamsBotApp(ConversationClient conversationClient, UserTokenClient userTokenClient, TeamsApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, BotApplicationOptions? options = null) : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options) + { + this.OnMessage(async (ctx, ct) => + { + await ctx.SendActivityAsync("Hello from MyTeamsBotApp!", ct); + }); + } +} diff --git a/core/samples/CustomHosting/Program.cs b/core/samples/CustomHosting/Program.cs new file mode 100644 index 00000000..068d7759 --- /dev/null +++ b/core/samples/CustomHosting/Program.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CustomHosting; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Core.Hosting; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +webApp.MapGet("/", () => $"Teams Bot App is running {TeamsBotApplication.Version}."); +webApp.UseBotApplication(); + +webApp.Run(); diff --git a/core/samples/CustomHosting/appsettings.json b/core/samples/CustomHosting/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/core/samples/CustomHosting/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/Directory.Build.props b/core/samples/Directory.Build.props new file mode 100644 index 00000000..f4880b17 --- /dev/null +++ b/core/samples/Directory.Build.props @@ -0,0 +1,6 @@ + + + all + true + + diff --git a/core/samples/MeetingsBot/MeetingsBot.csproj b/core/samples/MeetingsBot/MeetingsBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/MeetingsBot/MeetingsBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/MeetingsBot/Program.cs b/core/samples/MeetingsBot/Program.cs new file mode 100644 index 00000000..6f686ca3 --- /dev/null +++ b/core/samples/MeetingsBot/Program.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +// ==================== MEETING HANDLERS ==================== + +teamsApp.OnMeetingStart(async (context, cancellationToken) => +{ + MeetingStartValue? meeting = context.Activity.Value; + Console.WriteLine($"[MeetingStart] Title: {meeting?.Title}"); + await context.SendActivityAsync($"Meeting started: **{meeting?.Title}**", cancellationToken); +}); + +teamsApp.OnMeetingEnd(async (context, cancellationToken) => +{ + MeetingEndValue? meeting = context.Activity.Value; + Console.WriteLine($"[MeetingEnd] Title: {meeting?.Title}, EndTime: {meeting?.EndTime:u}"); + await context.SendActivityAsync($"Meeting ended: **{meeting?.Title}**\nEnd time: {meeting?.EndTime:u}", cancellationToken); +}); + +teamsApp.OnMeetingParticipantJoin(async (context, cancellationToken) => +{ + IList members = context.Activity.Value?.Members ?? []; + string names = string.Join(", ", members.Select(m => m.User.Name ?? m.User.Id)); + Console.WriteLine($"[MeetingParticipantJoin] Members: {names}"); + await context.SendActivityAsync($"Participant(s) joined: {names}", cancellationToken); +}); + +teamsApp.OnMeetingParticipantLeave(async (context, cancellationToken) => +{ + IList members = context.Activity.Value?.Members ?? []; + string names = string.Join(", ", members.Select(m => m.User.Name ?? m.User.Id)); + Console.WriteLine($"[MeetingParticipantLeave] Members: {names}"); + await context.SendActivityAsync($"Participant(s) left: {names}", cancellationToken); +}); + +//TODO : review if we can trigger these +// ==================== COMMAND HANDLERS ==================== +/* + +teamsApp.OnCommand(async (context, cancellationToken) => +{ + var commandId = context.Activity.Value?.CommandId ?? "unknown"; + Console.WriteLine($"[Command] CommandId: {commandId}"); + await context.SendActivityAsync($"Received command: **{commandId}**", cancellationToken); +}); + +teamsApp.OnCommandResult(async (context, cancellationToken) => +{ + var commandId = context.Activity.Value?.CommandId ?? "unknown"; + var error = context.Activity.Value?.Error; + Console.WriteLine($"[CommandResult] CommandId: {commandId}, HasError: {error is not null}"); + + if (error is not null) + await context.SendActivityAsync($"Command **{commandId}** failed: {error.Message}", cancellationToken); + else + await context.SendActivityAsync($"Command **{commandId}** completed successfully.", cancellationToken); +}); +*/ +webApp.Run(); diff --git a/core/samples/MeetingsBot/README.md b/core/samples/MeetingsBot/README.md new file mode 100644 index 00000000..db4b6eaf --- /dev/null +++ b/core/samples/MeetingsBot/README.md @@ -0,0 +1,51 @@ +# Sample: Meetings + +This sample demonstrates how to handle real-time updates for meeting events and meeting participant events. + +## Manifest Requirements + +There are a few requirements in the Teams app manifest (manifest.json) to support these events. + +1) The `scopes` section must include `team`, and `groupChat`: + +```json + "bots": [ + { + "botId": "", + "scopes": [ + "team", + "personal", + "groupChat" + ], + "isNotificationOnly": false + } + ] +``` + +2) In the authorization section, make sure to specify the following resource-specific permissions: + +```json + "authorization":{ + "permissions":{ + "resourceSpecific":[ + { + "name":"OnlineMeetingParticipant.Read.Chat", + "type":"Application" + }, + { + "name":"ChannelMeeting.ReadBasic.Group", + "type":"Application" + }, + { + "name":"OnlineMeeting.ReadBasic.Chat", + "type":"Application" + } + ] + } + } +``` + +### Teams Developer Portal: Bot Configuration + +For your Bot, make sure the [Meeting Event Subscriptions](https://learn.microsoft.com/en-us/microsoftteams/platform/apps-in-teams-meetings/meeting-apps-apis?branch=pr-en-us-8455&tabs=channel-meeting%2Cguest-user%2Cone-on-one-call%2Cdotnet3%2Cdotnet2%2Cdotnet%2Cparticipant-join-event%2Cparticipant-join-event1#receive-meeting-participant-events) are checked. +This enables you to receive the Meeting Participant events. diff --git a/core/samples/MeetingsBot/appsettings.json b/core/samples/MeetingsBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/MeetingsBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/MessageExtensionBot/Cards.cs b/core/samples/MessageExtensionBot/Cards.cs new file mode 100644 index 00000000..e2751529 --- /dev/null +++ b/core/samples/MessageExtensionBot/Cards.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace MessageExtensionBot; + +public static class Cards +{ + public static object[] CreateQueryResultCards(string searchText) + { + return new[] + { + new + { + title = $"Result 1: {searchText}", + text = "Click to see full details", + tap = new + { + type = "invoke", + value = new + { + itemId = "item-1", + title = $"Full details for Result 1: {searchText}", + description = "This is the expanded content" + } + } + }, + new + { + title = $"Result 2: {searchText}", + text = "Click to see full details", + tap = new + { + type = "invoke", + value = new + { + itemId = "item-2", + title = $"Full details for Result 2: {searchText}", + description = "This is more expanded content" + } + } + } + }; + } + + public static object CreateSelectItemCard(string? itemId, string? title, string? description) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = title, size = "large", weight = "bolder" }, + new { type = "TextBlock", text = description, wrap = true }, + new { type = "FactSet", facts = new[] + { + new { title = "Item ID:", value = itemId } + } + } + } + }; + } + + public static object CreateFetchTaskCard(string? commandId) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = $"Fetch Task for: {commandId}", size = "large", weight = "bolder" }, + new { type = "Input.Text", id = "title", label = "Title", placeholder = "Enter a title" }, + new { type = "Input.Text", id = "description", label = "Description", placeholder = "Enter a description", isMultiline = true } + }, + actions = new object[] + { + new { type = "Action.Submit", title = "Submit" } + } + }; + } + + public static object CreateEditFormCard(string? previewTitle, string? previewDescription) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = "Edit Your Card", size = "large", weight = "bolder" }, + new { type = "Input.Text", id = "title", label = "Title", placeholder = "Enter a title", value = previewTitle }, + new { type = "Input.Text", id = "description", label = "Description", placeholder = "Enter a description", isMultiline = true, value = previewDescription } + }, + actions = new object[] { new { type = "Action.Submit", title = "Submit" } } + }; + } + + public static object CreateSubmitActionCard(string? title, string? description) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = title ?? "Untitled", size = "large", weight = "bolder", color = "accent" }, + new { type = "TextBlock", text = description ?? "No description", wrap = true } + } + }; + } + + public static object CreateLinkUnfurlCard(string? url) + { + return new { title = $"Link Unfurled: {url}" }; + } +} diff --git a/core/samples/MessageExtensionBot/MessageExtensionBot.csproj b/core/samples/MessageExtensionBot/MessageExtensionBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/MessageExtensionBot/MessageExtensionBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/MessageExtensionBot/Program.cs b/core/samples/MessageExtensionBot/Program.cs new file mode 100644 index 00000000..8583a359 --- /dev/null +++ b/core/samples/MessageExtensionBot/Program.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using MessageExtensionBot; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// ==================== MESSAGE EXTENSION QUERY ==================== +bot.OnQuery(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQuery"); + + MessageExtensionQuery? query = context.Activity.Value; + string commandId = query?.CommandId ?? "unknown"; + string searchText = query?.Parameters + .FirstOrDefault(p => !p.Name.Equals("initialRun"))? + .Value ?? "default"; + + if (searchText.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Message) + .WithText("💡 Search for any keyword to see results.") + .Build(); + } + + // Create results with tap actions to trigger OnSelectItem + object[] cards = Cards.CreateQueryResultCards(searchText); + TeamsAttachment[] attachments = [.. cards.Select(card => TeamsAttachment.CreateBuilder().WithContent(card) + .WithContentType(AttachmentContentType.ThumbnailCard).Build())]; + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachments) + .Build(); +}); + +// ==================== MESSAGE EXTENSION SELECT ITEM ==================== +bot.OnSelectItem(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSelectItem"); + + JsonElement selectedItem = context.Activity.Value; + JsonElement? itemData = selectedItem; + string? itemId = itemData.Value.TryGetProperty("itemId", out JsonElement id) ? id.GetString() : "unknown"; + string? title = itemData.Value.TryGetProperty("title", out JsonElement t) ? t.GetString() : "Selected Item"; + string? description = itemData.Value.TryGetProperty("description", out JsonElement d) ? d.GetString() : "No description"; + + object card = Cards.CreateSelectItemCard(itemId, title, description); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder().WithAdaptiveCard(card).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + +// ==================== MESSAGE EXTENSION FETCH TASK ==================== +bot.OnFetchTask(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnFetchTask"); + + MessageExtensionAction? action = context.Activity.Value; + + object fetchTaskCard = Cards.CreateFetchTaskCard(action?.CommandId ?? "unknown"); + TeamsAttachment fetchTaskCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(fetchTaskCard).Build(); + return MessageExtensionActionResponse.CreateBuilder() + .WithTask(TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Task Module") + .WithCard(fetchTaskCardResponse)) + .Build(); +}); + +// Helper: Extract title and description from preview card +static (string?, string?) GetDataFromPreview(TeamsActivity? preview) +{ + if (preview?.Attachments == null) return (null, null); + + JsonElement cardData = JsonSerializer.Deserialize( + JsonSerializer.Serialize(preview.Attachments[0].Content)); + + if (!cardData.TryGetProperty("body", out JsonElement body) || body.ValueKind != JsonValueKind.Array) + return (null, null); + + string? title = body.GetArrayLength() > 0 && body[0].TryGetProperty("text", out JsonElement t) ? t.GetString() : null; + string? description = body.GetArrayLength() > 1 && body[1].TryGetProperty("text", out JsonElement d) ? d.GetString() : null; + + return (title, description); +} + + +// ==================== MESSAGE EXTENSION SUBMIT ACTION ==================== +bot.OnSubmitAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSubmitAction"); + + MessageExtensionAction? action = context.Activity.Value; + + // Handle "edit" - user clicked edit on the preview, show the form again + if (action?.BotMessagePreviewAction == "edit") + { + Console.WriteLine("Handling EDIT action - returning to form"); + (string? previewTitle, string? previewDescription) = GetDataFromPreview(action.BotActivityPreview?.FirstOrDefault()); + + object editFormCard = Cards.CreateEditFormCard(previewTitle, previewDescription); + TeamsAttachment editFormCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(editFormCard).Build(); + return MessageExtensionActionResponse.CreateBuilder() + .WithTask(TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Edit Card") + .WithCard(editFormCardResponse)) + .Build(); + } + + // Handle "send" - user clicked send on the preview, finalize the card + //TODO : when I start from the compose box or message, i get an error at this point but seems to be a teams issue ( no activity is sent on clicking send) + if (action?.BotMessagePreviewAction == "send") + { + Console.WriteLine("Handling SEND action - finalizing card"); + (string? previewTitle, string? previewDescription) = GetDataFromPreview(action.BotActivityPreview?.FirstOrDefault()); + + object card = Cards.CreateSubmitActionCard(previewTitle, previewDescription); + TeamsAttachment attachment2 = TeamsAttachment.CreateBuilder().WithAdaptiveCard(card).Build(); + + return MessageExtensionActionResponse.CreateBuilder() + .WithComposeExtension(MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment2)) + .Build(); + } + + + JsonElement? data = action?.Data as JsonElement?; + string? title = data != null && data.Value.TryGetProperty("title", out JsonElement t) ? t.GetString() : "Untitled"; + string? description = data != null && data.Value.TryGetProperty("description", out JsonElement d) ? d.GetString() : "No description"; + + object previewCard = Cards.CreateSubmitActionCard(title, description); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder().WithAdaptiveCard(previewCard).Build(); + + return MessageExtensionActionResponse.CreateBuilder() + .WithComposeExtension(MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.BotMessagePreview) + .WithActivityPreview(new MessageActivity([attachment])) + ) + .Build(); +}); + +// ==================== MESSAGE EXTENSION QUERY LINK ==================== +bot.OnQueryLink(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQueryLink"); + + MessageExtensionQueryLink? queryLink = context.Activity.Value; + + object card = Cards.CreateLinkUnfurlCard(queryLink?.Url?.ToString()); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContent(card).WithContentType(AttachmentContentType.ThumbnailCard).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + +// ==================== MESSAGE EXTENSION ANON QUERY LINK ==================== +//TODO : difficult to test, app must be published to catalog +bot.OnAnonQueryLink(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnAnonQueryLink"); + + MessageExtensionQueryLink? anonQueryLink = context.Activity.Value; + if (anonQueryLink != null) + { + Console.WriteLine($" URL: '{anonQueryLink.Url}'"); + } + + object card = Cards.CreateLinkUnfurlCard(anonQueryLink?.Url?.ToString()); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContent(card).WithContentType(AttachmentContentType.ThumbnailCard).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + + +// ==================== MESSAGE EXTENSION QUERY SETTING URL ==================== +bot.OnQuerySettingUrl(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQuerySettingUrl"); + + MessageExtensionQuery? query = context.Activity.Value; + + var action = new + { + Type = "openUrl", + Value = "https://www.microsoft.com" + }; + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Config) + .WithSuggestedActions([action]) + .Build(); +}); + + +//TODO : this is deprecated ? +// ==================== MESSAGE EXTENSION CARD BUTTON CLICKED ==================== +//bot.OnCardButtonClicked(async (context, cancellationToken) => +//{ +// Console.WriteLine("✓ OnCardButtonClicked"); +// Console.WriteLine($" Activity Type: {context.Activity.GetType().Name}"); +// +// return new CoreInvokeResponse(200); +//}); + +//TODO : only able to get OnQuerySettingUrl activity, how do we get onSetting or OnConfigFetch +/* +// ==================== MESSAGE EXTENSION SETTING ==================== +bot.OnSetting(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSetting"); + + var query = context.Activity.Value; + if (query != null) + { + Console.WriteLine($" Command ID: '{query.CommandId}'"); + } + + var action = new MessagingExtensionAction + { + Type = "openUrl", + Value = "https://microsoft.com", + Title = "Configure Settings" + }; + + var response = MessagingExtensionResponse.CreateBuilder() + .WithType(MessagingExtensionResponseType.Config) + .WithSuggestedActions(action) + .Build(); + + return new CoreInvokeResponse(200, response); +}); +*/ + +webApp.Run(); diff --git a/core/samples/MessageExtensionBot/README.md b/core/samples/MessageExtensionBot/README.md new file mode 100644 index 00000000..f8062b00 --- /dev/null +++ b/core/samples/MessageExtensionBot/README.md @@ -0,0 +1,55 @@ +# MessageExtensionBot Testing Guide + +A sample bot demonstrating Teams message extension handlers. + +## Setup + +1. Configure bot credentials in `appsettings.json` or environment variables +2. Run the bot: `dotnet run` +3. Upload `manifest.json` to Teams + +## Testing Handlers + +### OnQuery (Search) +**Manifest:** `composeExtensions.commands` with `type: "query"` + +1. Open message compose box +2. Select the message extension +3. Type a search term +4. Verify results display in list format +5. Type "help" to test message response + +### OnSelectItem +**Manifest:** No specific requirement (works with OnQuery results) + +1. After running a search (OnQuery) +2. Click on any search result +3. Verify adaptive card preview appears + +### OnFetchTask (Action - Task Module) +**Manifest:** `composeExtensions.commands` with `type: "action"` and `fetchTask: true` + +1. Click the message extension action button (createAction) +2. Verify task module opens with input form + +### OnSubmitAction (Action Submit) +**Manifest:** No specific requirement (works with OnFetchTask) + +1. Fill form in task module +2. Click submit +3. Verify preview card appears with Edit/Send buttons +4. Click Edit - verify form reopens with values +5. Click Send - verify final card posts to conversation -- Currently this only works when we start from commandbox. + +### OnQueryLink (Link Unfurling) +**Manifest:** `composeExtensions.messageHandlers` with `type: "link"` and `domains` + +1. Paste a URL in compose box that matches the unfurl domain in manifest (*.example.com) +2. Verify card unfurls automatically + +### OnQuerySettingUrl (Settings) +**Manifest:** `composeExtensions.canUpdateConfiguration: true` + +1. Right-click message extension icon +2. Select Settings +3. Verify settings URL opens (microsoft.com) diff --git a/core/samples/MessageExtensionBot/appsettings.json b/core/samples/MessageExtensionBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/MessageExtensionBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/MessageExtensionBot/manifest.json b/core/samples/MessageExtensionBot/manifest.json new file mode 100644 index 00000000..d6c36e98 --- /dev/null +++ b/core/samples/MessageExtensionBot/manifest.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "1.24", + "id": "YOUR_BOT_ID", + "name": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://microsoft.com", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/legal/terms-of-use" + }, + "description": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": [ + "personal" + ] + }, + { + "entityId": "about", + "scopes": [ + "personal" + ] + } + ], + "bots": [ + { + "botId": "YOUR_BOT_ID", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": false + } + ], + "composeExtensions": [ + { + "botId": "YOUR_BOT_ID", + "commands": [ + { + "id": "searchQuery", + "type": "query", + "title": "searchQuery", + "description": "Enter search text", + "initialRun": true, + "fetchTask": false, + "context": [ + "commandBox", + "compose", + "message" + ], + "parameters": [ + { + "name": "searchText", + "title": "searchText", + "description": "Enter search text", + "inputType": "text" + } + ] + }, + { + "id": "createAction", + "type": "action", + "title": "createAction", + "description": "Create a new item", + "initialRun": true, + "fetchTask": true, + "context": [ + "commandBox", + "compose", + "message" + ], + "parameters": [ + { + "name": "createAction", + "title": "createAction", + "description": "Create a new item", + "inputType": "text" + } + ] + } + ], + "canUpdateConfiguration": true, + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "*.example.com", + "*.microsoft.com" + ], + "supportsAnonymizedPayloads": true + } + } + ] + } + ], + "validDomains": [ + "*.microsoft.com" + ], + "webApplicationInfo": { + "id": "YOUR_BOT_ID", + "resource": "https://graph.microsoft.com" + } +} diff --git a/core/samples/PABot/AdapterWithErrorHandler.cs b/core/samples/PABot/AdapterWithErrorHandler.cs new file mode 100644 index 00000000..c349993a --- /dev/null +++ b/core/samples/PABot/AdapterWithErrorHandler.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Compat; + +namespace PABot +{ + public class AdapterWithErrorHandler : CompatAdapter + { + public AdapterWithErrorHandler( + TeamsBotApplication teamsBotApp, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + ILogger logger, + IStorage storage, + ConversationState conversationState + ) + : base( + teamsBotApp, + httpContextAccessor, + logger) + { + base.Use(new TeamsSSOTokenExchangeMiddleware(storage, configuration["ConnectionName"] ?? "graph")); + + OnTurnError = async (turnContext, exception) => + { + // Log any leaked exception from the application. + // NOTE: In production environment, you should consider logging this to + // Azure Application Insights. Visit https://aka.ms/bottelemetry to see how + // to add telemetry capture to your bot. + logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}"); + + // Uncomment below commented line for local debugging.. + // await turnContext.SendActivityAsync($"Sorry, it looks like something went wrong. Exception Caught: {exception.Message}"); + + if (conversationState != null) + { + try + { + // Delete the conversationState for the current conversation to prevent the + // bot from getting stuck in a error-loop caused by being in a bad state. + // ConversationState should be thought of as similar to "cookie-state" in a Web pages. + await conversationState.DeleteAsync(turnContext); + } + catch (Exception e) + { + logger.LogError(e, $"Exception caught on attempting to Delete ConversationState : {e.Message}"); + } + } + + // Send a trace activity, which will be displayed in the Bot Framework Emulator + await turnContext.TraceActivityAsync( + "OnTurnError Trace", + exception.Message, + "https://www.botframework.com/schemas/error", + "TurnError"); + }; + } + } +} diff --git a/core/samples/PABot/Bots/DialogBot.cs b/core/samples/PABot/Bots/DialogBot.cs new file mode 100644 index 00000000..02004832 --- /dev/null +++ b/core/samples/PABot/Bots/DialogBot.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + /// + /// This IBot implementation can run any type of Dialog. The use of type parameterization allows multiple different bots + /// to be run at different endpoints within the same project. This can be achieved by defining distinct Controller types + /// each with dependency on distinct IBot types, this way ASP Dependency Injection can glue everything together without ambiguity. + /// The ConversationState is used by the Dialog system. The UserState isn't, however, it might have been used in a Dialog implementation, + /// and the requirement is that all BotState objects are saved at the end of a turn. + /// + /// The type of the dialog. + public class DialogBot : TeamsActivityHandler where T : Dialog + { + protected readonly BotState _conversationState; + protected readonly Dialog _dialog; + protected readonly ILogger _logger; + protected readonly BotState _userState; + + /// + /// Initializes a new instance of the class. + /// + /// The conversation state. + /// The user state. + /// The dialog. + /// The logger. + public DialogBot(ConversationState conversationState, UserState userState, T dialog, ILogger> logger) + { + _conversationState = conversationState; + _userState = userState; + _dialog = dialog; + _logger = logger; + } + + /// + /// Handles an incoming activity. + /// + /// Context object containing information cached for a single turn of conversation with a user. + /// Propagates notification that operations should be canceled. + /// A task that represents the work queued to execute. + /// + /// Reference link: https://docs.microsoft.com/en-us/dotnet/api/microsoft.bot.builder.activityhandler.onturnasync?view=botbuilder-dotnet-stable. + /// + public override async Task OnTurnAsync( + ITurnContext turnContext, + CancellationToken cancellationToken = default(CancellationToken)) + { + await base.OnTurnAsync(turnContext, cancellationToken); + + // Save any state changes that might have occurred during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + await _userState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + /// + /// Handles when a message is addressed to the bot. + /// + /// Context object containing information cached for a single turn of conversation with a user. + /// Propagates notification that operations should be canceled. + /// A Task resolving to either a login card or the adaptive card of the Reddit post. + /// + /// For more information on bot messaging in Teams, see the documentation + /// https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics?tabs=dotnet#receive-a-message. + /// + protected override async Task OnMessageActivityAsync( + ITurnContext turnContext, + CancellationToken cancellationToken) + { + _logger.LogInformation("Running dialog with Message Activity."); + + await _dialog.RunAsync(turnContext, _conversationState.CreateProperty(nameof(DialogState)), cancellationToken); + } + } +} diff --git a/core/samples/PABot/Bots/EchoBot.cs b/core/samples/PABot/Bots/EchoBot.cs new file mode 100644 index 00000000..a0ee8747 --- /dev/null +++ b/core/samples/PABot/Bots/EchoBot.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + public class EchoBot : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), cancellationToken); + } + } +} diff --git a/core/samples/PABot/Bots/SsoBot.cs b/core/samples/PABot/Bots/SsoBot.cs new file mode 100644 index 00000000..4bd59cef --- /dev/null +++ b/core/samples/PABot/Bots/SsoBot.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + public class SsoBot(ILogger logger) : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), cancellationToken); + + UserTokenClient utc = turnContext.TurnState.Get(); + + TokenStatus[] tokenStatus = await utc.GetTokenStatusAsync(turnContext.Activity.From.Id, turnContext.Activity.ChannelId, string.Empty, cancellationToken); + + logger.LogInformation("Token status count"); + //logger.LogInformation(JsonConvert.SerializeObject(tokenStatus)); + await turnContext.SendActivityAsync($"Token status count: {tokenStatus.Length}"); + + foreach (TokenStatus ts in tokenStatus) + { + if (ts.HasToken == true) + { + TokenResponse tokenResponse = await utc.GetUserTokenAsync(turnContext.Activity.From.Id, turnContext.Activity.ChannelId, ts.ConnectionName, null, cancellationToken); + //logger.LogInformation("Token for connection '{ConnectionName}': {Token}", ts.ConnectionName, tokenResponse?.Token); + await turnContext.SendActivityAsync(MessageFactory.Text($"Token for connection '{ts.ConnectionName}': {tokenResponse?.Token}"), cancellationToken); + } + else + { + //logger.LogInformation("No token for connection '{ConnectionName}'", ts.ConnectionName); + await turnContext.SendActivityAsync(MessageFactory.Text($"No token for connection '{ts.ConnectionName}'"), cancellationToken); + + Activity? a = turnContext.Activity as Activity; + SignInResource signInResource = await utc.GetSignInResourceAsync(ts.ConnectionName, a, string.Empty, cancellationToken); + //logger.LogInformation("Sign-in resource for connection '{ConnectionName}': {SignInLink}", ts.ConnectionName, signInResource.SignInLink); + await turnContext.SendActivityAsync(MessageFactory.Text($"Sign-in resource for connection '{ts.ConnectionName}': {signInResource.SignInLink}"), cancellationToken); + + } + } + + + } + } +} diff --git a/core/samples/PABot/Bots/TeamsBot.cs b/core/samples/PABot/Bots/TeamsBot.cs new file mode 100644 index 00000000..56365d34 --- /dev/null +++ b/core/samples/PABot/Bots/TeamsBot.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + /// + /// This bot is derived from the TeamsActivityHandler class and handles Teams-specific activities. + /// + /// The type of the dialog. + public class TeamsBot : DialogBot where T : Dialog + { + /// + /// Initializes a new instance of the class. + /// + /// The conversation state. + /// The user state. + /// The dialog. + /// The logger. + public TeamsBot(ConversationState conversationState, UserState userState, T dialog, ILogger> logger) + : base(conversationState, userState, dialog, logger) + { + } + + /// + /// Handles the event when members are added to the conversation. + /// + /// The list of members added. + /// The turn context. + /// The cancellation token. + /// A task that represents the work queued to execute. + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + foreach (ChannelAccount member in membersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Welcome to AuthenticationBot. Type anything to get logged in. Type 'logout' to sign-out."), cancellationToken); + } + } + } + + /// + /// Handles the Teams sign-in verification state. + /// + /// The turn context. + /// The cancellation token. + /// A task that represents the work queued to execute. + protected override async Task OnTeamsSigninVerifyStateAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + _logger.LogInformation("Running dialog with sign-in/verify state from an Invoke Activity."); + + // The OAuth Prompt needs to see the Invoke Activity in order to complete the login process. + // Run the Dialog with the new Invoke Activity. + await _dialog.RunAsync(turnContext, _conversationState.CreateProperty(nameof(DialogState)), cancellationToken); + } + } +} diff --git a/core/samples/PABot/Dialogs/LogoutDialog.cs b/core/samples/PABot/Dialogs/LogoutDialog.cs new file mode 100644 index 00000000..24cb4283 --- /dev/null +++ b/core/samples/PABot/Dialogs/LogoutDialog.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; + +namespace PABot.Dialogs +{ + /// + /// A dialog that handles user logout. + /// + public class LogoutDialog : ComponentDialog + { + /// + /// Initializes a new instance of the class. + /// + /// The dialog ID. + /// The connection name configured in Azure Bot service. + public LogoutDialog(string id, string connectionName) + : base(id) + { + ConnectionName = connectionName; + } + + /// + /// Gets the configured connection name in Azure Bot service. + /// + protected string ConnectionName { get; } + + /// + /// Called when the dialog is started and pushed onto the parent's dialog stack. + /// + /// The inner DialogContext for the current turn of conversation. + /// Initial information to pass to the dialog. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + protected override async Task OnBeginDialogAsync( + DialogContext innerDc, + object options, + CancellationToken cancellationToken = default(CancellationToken)) + { + DialogTurnResult result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnBeginDialogAsync(innerDc, options, cancellationToken); + } + + /// + /// Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. + /// + /// The inner DialogContext for the current turn of conversation. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + protected override async Task OnContinueDialogAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default(CancellationToken)) + { + DialogTurnResult result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnContinueDialogAsync(innerDc, cancellationToken); + } + + /// + /// Called when the dialog is interrupted, where it is the active dialog and the user replies with a new activity. + /// + /// The inner DialogContext for the current turn of conversation. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + private async Task InterruptAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (innerDc.Context.Activity.Type == ActivityTypes.Message) + { + string text = innerDc.Context.Activity.Text.ToLowerInvariant(); + + // Allow logout anywhere in the command + if (text.Contains("logout")) + { + // The UserTokenClient encapsulates the authentication processes. + UserTokenClient userTokenClient = innerDc.Context.TurnState.Get(); + await userTokenClient.SignOutUserAsync(innerDc.Context.Activity.From.Id, ConnectionName, innerDc.Context.Activity.ChannelId, cancellationToken).ConfigureAwait(false); + + await innerDc.Context.SendActivityAsync(MessageFactory.Text("You have been signed out."), cancellationToken); + return await innerDc.CancelAllDialogsAsync(cancellationToken); + } + } + + return null!; + } + } +} diff --git a/core/samples/PABot/Dialogs/MainDialog.cs b/core/samples/PABot/Dialogs/MainDialog.cs new file mode 100644 index 00000000..3344fa4a --- /dev/null +++ b/core/samples/PABot/Dialogs/MainDialog.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Microsoft.Graph.Models; + +namespace PABot.Dialogs +{ + /// + /// Main dialog that handles the authentication and user interactions. + /// + public class MainDialog : LogoutDialog + { + protected readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + /// The logger. + public MainDialog(IConfiguration configuration, ILogger logger) + : base(nameof(MainDialog), configuration["ConnectionName"] ?? "graph") + { + _logger = logger; + + AddDialog(new OAuthPrompt( + nameof(OAuthPrompt), + new OAuthPromptSettings + { + ConnectionName = ConnectionName, + Text = "Please Sign In", + Title = "Sign In", + Timeout = 300000, // User has 5 minutes to login (1000 * 60 * 5) + EndOnInvalidMessage = true + })); + + AddDialog(new ConfirmPrompt(nameof(ConfirmPrompt))); + + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] + { + PromptStepAsync, + LoginStepAsync, + DisplayTokenPhase1Async, + DisplayTokenPhase2Async, + })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + /// + /// Prompts the user to sign in. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("PromptStepAsync() called."); + return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken); + } + + /// + /// Handles the login step. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task LoginStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + TokenResponse tokenResponse = (TokenResponse)stepContext.Result; + if (tokenResponse?.Token != null) + { + try + { + SimpleGraphClient client = new(tokenResponse.Token); + User me = await client.GetMeAsync(); + string title = !string.IsNullOrEmpty(me.JobTitle) ? me.JobTitle : "Unknown"; + + await stepContext.Context.SendActivityAsync($"You're logged in as {me.DisplayName} ({me.UserPrincipalName}); your job title is: {title}"); + + string photo = await client.GetPhotoAsync(); + + if (!string.IsNullOrEmpty(photo)) + { + CardImage cardImage = new(photo); + ThumbnailCard card = new(images: new List { cardImage }); + IMessageActivity reply = MessageFactory.Attachment(card.ToAttachment()); + + await stepContext.Context.SendActivityAsync(reply, cancellationToken); + } + else + { + await stepContext.Context.SendActivityAsync(MessageFactory.Text("Sorry! User doesn't have a profile picture to display."), cancellationToken); + } + + return await stepContext.PromptAsync( + nameof(ConfirmPrompt), + new PromptOptions { Prompt = MessageFactory.Text("Would you like to view your token?") }, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while processing your request."); + } + } + else + { + _logger.LogInformation("Response token is null or empty."); + } + + await stepContext.Context.SendActivityAsync(MessageFactory.Text("Login was not successful, please try again."), cancellationToken); + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + /// + /// Displays the token if the user confirms. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task DisplayTokenPhase1Async(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("DisplayTokenPhase1Async() method called."); + + await stepContext.Context.SendActivityAsync(MessageFactory.Text("Thank you."), cancellationToken); + + bool result = (bool)stepContext.Result; + if (result) + { + return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), cancellationToken: cancellationToken); + } + + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + /// + /// Displays the token to the user. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task DisplayTokenPhase2Async(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("DisplayTokenPhase2Async() method called."); + + TokenResponse tokenResponse = (TokenResponse)stepContext.Result; + if (tokenResponse != null) + { + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Here is your token: {tokenResponse.Token}"), cancellationToken); + } + + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + } +} diff --git a/core/samples/PABot/InitCompatAdapter.cs b/core/samples/PABot/InitCompatAdapter.cs new file mode 100644 index 00000000..c74ddac1 --- /dev/null +++ b/core/samples/PABot/InitCompatAdapter.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace PABot +{ + internal static class InitCompatAdapter + { + private const string DefaultScope = "https://api.botframework.com/.default"; + + public static IServiceCollection AddTeamsBotApplications(this IServiceCollection services) + { + // Register shared services (needed once for all adapters) + services.AddHttpClient(); + services.AddTokenAcquisition(true); + services.AddInMemoryTokenCaches(); + services.AddAgentIdentities(); + services.AddHttpContextAccessor(); + + // Register each keyed adapter instance + RegisterKeyedTeamsBotApplication(services, "AdapterOne"); + RegisterKeyedTeamsBotApplication(services, "AdapterTwo"); + + return services; + } + + private static void RegisterKeyedTeamsBotApplication(IServiceCollection services, string keyName) + { + // Get configuration for this key + IConfigurationSection configSection = services.BuildServiceProvider().GetRequiredService().GetSection(keyName); + + // Configure authorization and authentication for this key + // This sets up JWT bearer authentication and authorization policies + services.AddBotAuthorization(null, keyName); + + // Configure MSAL options for this key + services.Configure(keyName, configSection); + + // Register named HttpClients with custom auth handlers + services.AddHttpClient($"{keyName}_ConversationClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, keyName, DefaultScope)); + + services.AddHttpClient($"{keyName}_UserTokenClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, keyName, DefaultScope)); + + services.AddHttpClient($"{keyName}_TeamsApiClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, keyName, DefaultScope)); + + // Register keyed ConversationClient + services.AddKeyedSingleton(keyName, (sp, key) => + { + HttpClient httpClient = sp.GetRequiredService() + .CreateClient($"{keyName}_ConversationClient"); + return new ConversationClient(httpClient, sp.GetRequiredService>()); + }); + + // Register keyed UserTokenClient + services.AddKeyedSingleton(keyName, (sp, key) => + { + HttpClient httpClient = sp.GetRequiredService() + .CreateClient($"{keyName}_UserTokenClient"); + return new UserTokenClient( + httpClient, + sp.GetRequiredService(), + sp.GetRequiredService>()); + }); + + // Register keyed TeamsApiClient + services.AddKeyedSingleton(keyName, (sp, key) => + { + HttpClient httpClient = sp.GetRequiredService() + .CreateClient($"{keyName}_TeamsApiClient"); + return new TeamsApiClient(httpClient, sp.GetRequiredService>()); + }); + + + // Register keyed TeamsBotApplication + services.AddKeyedSingleton(keyName, (sp, key) => + { + return new TeamsBotApplication( + sp.GetRequiredKeyedService(keyName), + sp.GetRequiredKeyedService(keyName), + sp.GetRequiredKeyedService(keyName), + sp.GetRequiredService(), + sp.GetRequiredService>() + ); + }); + } + + private static DelegatingHandler CreatePACustomAuthHandler( + IServiceProvider sp, + string keyName, + string scope) + { + return new PACustomAuthHandler( + keyName, + sp.GetRequiredService(), + sp.GetRequiredService>(), + scope, + sp.GetService>()); + } + } +} diff --git a/core/samples/PABot/PABot.csproj b/core/samples/PABot/PABot.csproj new file mode 100644 index 00000000..ffcd3bcf --- /dev/null +++ b/core/samples/PABot/PABot.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/core/samples/PABot/PACustomAuthHandler.cs b/core/samples/PABot/PACustomAuthHandler.cs new file mode 100644 index 00000000..04a524ad --- /dev/null +++ b/core/samples/PABot/PACustomAuthHandler.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Bot.Core.Schema; + +namespace PABot +{ + internal class PACustomAuthHandler( + string msalOptionName, + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger, + string scope, + IOptions? managedIdentityOptions = null) : DelegatingHandler + { + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + private readonly IOptions? _managedIdentityOptions = managedIdentityOptions; + + /// + /// Key used to store the agentic identity in HttpRequestMessage options. + /// + public static readonly HttpRequestOptionsKey AgenticIdentityKey = new("AgenticIdentity"); + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Options.TryGetValue(AgenticIdentityKey, out AgenticIdentity? agenticIdentity); + + string token = await GetAuthorizationHeaderAsync(agenticIdentity, cancellationToken).ConfigureAwait(false); + + string tokenValue = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? token["Bearer ".Length..] + : token; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenValue); + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets an authorization header for Bot Framework API calls. + /// Supports both app-only and agentic (user-delegated) token acquisition. + /// + /// Optional agentic identity for user-delegated token acquisition. If not provided, acquires an app-only token. + /// Cancellation token. + /// The authorization header value. + private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) + { + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = msalOptionName + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + options.AcquireTokenOptions.ManagedIdentity = miOptions; + } + } + + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logger.LogInformation("Acquiring agentic token for scope '{Scope}' with AppId '{AppId}' and UserId '{UserId}'.", + _scope, + agenticIdentity.AgenticAppId, + agenticIdentity.AgenticUserId); + + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, Guid.Parse(agenticIdentity.AgenticUserId)); + string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken).ConfigureAwait(false); + return token; + } + + _logger.LogInformation("Acquiring app-only token for scope: {Scope}", _scope); + string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken).ConfigureAwait(false); + return appToken; + } + } +} diff --git a/core/samples/PABot/Program.cs b/core/samples/PABot/Program.cs new file mode 100644 index 00000000..9995235e --- /dev/null +++ b/core/samples/PABot/Program.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Teams.Bot.Apps; +using PABot; +using PABot.Bots; +using PABot.Dialogs; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Register all the keyed services (ConversationClient, UserTokenClient, TeamsApiClient, TeamsBotApplication) +builder.Services.AddTeamsBotApplications(); + +// Register keyed adapters using the keyed TeamsBotApplication +builder.Services.AddKeyedSingleton("AdapterOne", (sp, keyName) => +{ + return new AdapterWithErrorHandler( + sp.GetRequiredKeyedService("AdapterOne"), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService()); +}); + +builder.Services.AddKeyedSingleton("AdapterTwo", (sp, keyName) => +{ + return new AdapterWithErrorHandler( + sp.GetRequiredKeyedService("AdapterTwo"), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService()); +}); + +// Register bot state and dialog +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register bots +builder.Services.AddKeyedTransient>("TeamsBot"); +builder.Services.AddKeyedTransient("EchoBot"); + +WebApplication app = builder.Build(); + +// Get the keyed adapters +IBotFrameworkHttpAdapter adapterOne = app.Services.GetRequiredKeyedService("AdapterOne"); +IBotFrameworkHttpAdapter adapterTwo = app.Services.GetRequiredKeyedService("AdapterTwo"); + +// Map endpoints with their respective adapters and authorization policies +app.MapPost("/api/messages", (HttpRequest request, HttpResponse response, [FromKeyedServices("EchoBot")] IBot bot, CancellationToken ct) => + adapterOne.ProcessAsync(request, response, bot, ct)).RequireAuthorization("AdapterOne"); + +app.MapPost("/api/v2/messages", (HttpRequest request, HttpResponse response, [FromKeyedServices("TeamsBot")] IBot bot, CancellationToken ct) => + adapterTwo.ProcessAsync(request, response, bot, ct)).RequireAuthorization("AdapterTwo"); + +app.Run(); diff --git a/core/samples/PABot/Properties/launchSettings.TEMPLATE.json b/core/samples/PABot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 00000000..c6433d62 --- /dev/null +++ b/core/samples/PABot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "ridotest-local-msal": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ConnectionName": "graph", + "AdapterOne__Scope": "https://api.botframework.com/.default", + "AdapterOne__Instance": "https://login.microsoftonline.com/", + "AdapterOne__TenantId": "", + "AdapterOne__ClientId": "", + "AdapterOne__ClientCredentials__0__SourceType": "ClientSecret", + "AdapterOne__ClientCredentials__0__ClientSecret": "", + "AdapterTwo__Scope": "https://botapi.skype.com/.default", + "AdapterTwo__Instance": "https://login.microsoftonline.com/", + "AdapterTwo__TenantId": "", + "AdapterTwo__ClientId": "", + "AdapterTwo__ClientCredentials__0__SourceType": "ClientSecret", + "AdapterTwo__ClientCredentials__0__ClientSecret": "", + } + } + } + } diff --git a/core/samples/PABot/SimpleGraphClient.cs b/core/samples/PABot/SimpleGraphClient.cs new file mode 100644 index 00000000..a3461368 --- /dev/null +++ b/core/samples/PABot/SimpleGraphClient.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Graph; +using Microsoft.Graph.Me.SendMail; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Authentication; + + +namespace PABot +{ + /// + /// This class is a wrapper for the Microsoft Graph API. + /// See: https://developer.microsoft.com/en-us/graph + /// + public class SimpleGraphClient + { + private readonly string _token; + + /// + /// Initializes a new instance of the class. + /// + /// The token issued to the user. + public SimpleGraphClient(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentNullException(nameof(token)); + } + + _token = token; + } + + /// + /// Sends an email on the user's behalf using the Microsoft Graph API. + /// + /// The recipient's email address. + /// The subject of the email. + /// The content of the email. + /// A task representing the asynchronous operation. + public async Task SendMailAsync(string toAddress, string subject, string content) + { + if (string.IsNullOrWhiteSpace(toAddress)) + { + throw new ArgumentNullException(nameof(toAddress)); + } + + if (string.IsNullOrWhiteSpace(subject)) + { + throw new ArgumentNullException(nameof(subject)); + } + + if (string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentNullException(nameof(content)); + } + + GraphServiceClient graphClient = GetAuthenticatedClient(); + List recipients = new() + { + new() { + EmailAddress = new EmailAddress + { + Address = toAddress, + }, + }, + }; + + // Create the message. + Message email = new() + { + Body = new ItemBody + { + Content = content, + ContentType = BodyType.Text, + }, + Subject = subject, + ToRecipients = recipients, + }; + + // Send the message. + await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody { Message = email, SaveToSentItems = true }); + } + + /// + /// Gets recent mail for the user using the Microsoft Graph API. + /// + /// An array of recent messages. + public async Task GetRecentMailAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + MessageCollectionResponse? messages = await graphClient.Me.MailFolders["inbox"].Messages.GetAsync(); + + return messages?.Value?.Take(5).ToArray()!; + } + + /// + /// Gets information about the user. + /// + /// The user information. + public async Task GetMeAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + User? me = await graphClient.Me.GetAsync(); + return me!; + } + + /// + /// Gets the user's photo. + /// + /// The user's photo as a base64 string. + public async Task GetPhotoAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + Stream? photo = await graphClient.Me.Photo.Content.GetAsync(); + if (photo != null) + { + using MemoryStream ms = new(); + await photo.CopyToAsync(ms); + byte[] buffers = ms.ToArray(); + return $"data:image/png;base64,{Convert.ToBase64String(buffers)}"; + } + return string.Empty; + } + + /// + /// Gets an authenticated Microsoft Graph client using the token issued to the user. + /// + /// The authenticated GraphServiceClient. + private GraphServiceClient GetAuthenticatedClient() + { + SimpleAccessTokenProvider tokenProvider = new(_token); + + BaseBearerTokenAuthenticationProvider authProvider = new(tokenProvider); + + return new GraphServiceClient(authProvider); + } + + public class SimpleAccessTokenProvider : IAccessTokenProvider + { + private readonly string _accessToken; + + public SimpleAccessTokenProvider(string accessToken) + { + _accessToken = accessToken; + } + + public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? context = null!, CancellationToken cancellationToken = default) + { + return Task.FromResult(_accessToken); + } + + public AllowedHostsValidator AllowedHostsValidator => new(); + } + } +} diff --git a/core/samples/PABot/appsettings.json b/core/samples/PABot/appsettings.json new file mode 100644 index 00000000..2c63c085 --- /dev/null +++ b/core/samples/PABot/appsettings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/core/samples/Proactive/Proactive.csproj b/core/samples/Proactive/Proactive.csproj new file mode 100644 index 00000000..8cb14294 --- /dev/null +++ b/core/samples/Proactive/Proactive.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + diff --git a/core/samples/Proactive/Program.cs b/core/samples/Proactive/Program.cs new file mode 100644 index 00000000..85ccefa7 --- /dev/null +++ b/core/samples/Proactive/Program.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; + +using Proactive; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddConversationClient(); +builder.Services.AddHostedService(); + +IHost host = builder.Build(); +host.Run(); diff --git a/core/samples/Proactive/Worker.cs b/core/samples/Proactive/Worker.cs new file mode 100644 index 00000000..3ec509b7 --- /dev/null +++ b/core/samples/Proactive/Worker.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Proactive; + +public class Worker(ConversationClient conversationClient, ILogger logger) : BackgroundService +{ + private const string ConversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; + private const string FromId = "28:56653e9d-2158-46ee-90d7-675c39642038"; + private const string ServiceUrl = "https://smba.trafficmanager.net/teams/"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (logger.IsEnabled(LogLevel.Information)) + { + CoreActivity proactiveMessage = new() + { + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId }, + Conversation = new() { Id = ConversationId } + }; + proactiveMessage.Properties["text"] = $"Proactive hello at {DateTimeOffset.Now}"; + SendActivityResponse aid = await conversationClient.SendActivityAsync(proactiveMessage, cancellationToken: stoppingToken); + logger.LogInformation("Activity {Aid} sent", aid.Id); + } + await Task.Delay(1000, stoppingToken); + } + } +} diff --git a/core/samples/Proactive/appsettings.json b/core/samples/Proactive/appsettings.json new file mode 100644 index 00000000..e258d268 --- /dev/null +++ b/core/samples/Proactive/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Bot.Core": "Information" + } + } +} diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs new file mode 100644 index 00000000..c36c6cde --- /dev/null +++ b/core/samples/TabApp/Body.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace TabApp; + +public class PostToChatBody +{ + public required string Message { get; set; } + public string? ChatId { get; set; } + public string? ChannelId { get; set; } +} + +public record PostToChatResult(bool Ok); diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs new file mode 100644 index 00000000..820b7c1e --- /dev/null +++ b/core/samples/TabApp/Program.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Identity.Web; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using TabApp; + +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddBotAuthorization(); +builder.Services.AddConversationClient(); +WebApplication app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// ==================== TABS ==================== + +var contentTypes = new FileExtensionContentTypeProvider(); +app.MapGet("/tabs/test/{*path}", (string? path) => +{ + var root = Path.Combine(Directory.GetCurrentDirectory(), "Web", "bin"); + var full = Path.Combine(root, path ?? "index.html"); + contentTypes.TryGetContentType(full, out var ct); + return Results.File(File.OpenRead(full), ct ?? "text/html"); +}); + +// ==================== SERVER FUNCTIONS ==================== + +app.MapPost("/functions/post-to-chat", async ( + PostToChatBody body, + HttpContext httpCtx, + ConversationClient conversations, + IConfiguration config, + IMemoryCache cache, + ILogger logger, + CancellationToken ct) => +{ + logger.LogInformation("post-to-chat called"); + + var serviceUrl = new Uri("https://smba.trafficmanager.net/teams"); + string conversationId; + + if (body.ChatId is not null) + { + // group chat or 1:1 chat tab — chat ID is the conversation ID + conversationId = body.ChatId; + } + else if (body.ChannelId is not null) + { + // channel tab — post to the channel directly + conversationId = body.ChannelId; + } + else + { + // personal tab — create or reuse a 1:1 conversation + string userId = httpCtx.User.GetObjectId() ?? throw new InvalidOperationException("User object ID claim not found."); + + if (!cache.TryGetValue($"conv:{userId}", out string? cached)) + { + string botId = config["AzureAd:ClientId"] ?? throw new InvalidOperationException("Bot client ID not configured."); + string tenantId = httpCtx.User.GetTenantId() ?? throw new InvalidOperationException("Tenant ID claim not found."); + + CreateConversationResponse res = await conversations.CreateConversationAsync(new ConversationParameters + { + IsGroup = false, + TenantId = tenantId, + Members = [new TeamsConversationAccount { Id = userId }] + }, serviceUrl, cancellationToken: ct); + + cached = res.Id ?? throw new InvalidOperationException("CreateConversation returned no ID."); + cache.Set($"conv:{userId}", cached); + } + + conversationId = cached!; + } + + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Hello from the tab!") + .WithServiceUrl(serviceUrl) + .WithConversation(new TeamsConversation { Id = conversationId! }) + .Build(); + await conversations.SendActivityAsync(activity, cancellationToken: ct); + + return Results.Json(new PostToChatResult(Ok: true)); +}).RequireAuthorization(); + +app.Run(); diff --git a/core/samples/TabApp/README.md b/core/samples/TabApp/README.md new file mode 100644 index 00000000..30b6ba7b --- /dev/null +++ b/core/samples/TabApp/README.md @@ -0,0 +1,109 @@ +# TabApp + +A sample demonstrating a React/Vite tab served by the bot, with server functions and client-side Graph calls. + +| Feature | How it works | +|---|---| +| **Static tab** | Bot serves `Web/bin` via `app.WithTab("test", "./Web/bin")` at `/tabs/test` | +| **Teams Context** | Reads the raw Teams context via the Teams JS SDK | +| **Post to Chat** | Tab calls `POST /functions/post-to-chat` → bot sends a proactive message | +| **Who Am I** | Acquires a Graph token via MSAL and calls `GET /me` | +| **Toggle Presence** | Acquires a Graph token with `Presence.ReadWrite` and calls `POST /me/presence/setUserPreferredPresence` | + +--- + +## Azure App Registration + +### 1. Application ID URI + +Under **Expose an API → Application ID URI**, set it to: + +``` +api://{YOUR_CLIENT_ID} +``` + +Then add a scope named `access_as_user` and pre-authorize the Teams client IDs: + +| Client ID | App | +|---|---| +| `1fec8e78-bce4-4aaf-ab1b-5451cc387264` | Teams desktop / mobile | +| `5e3ce6c0-2b1f-4285-8d4b-75ee78787346` | Teams web | + +### 2. Redirect URI + +Under **Authentication → Add a platform → Single-page application**, add: + +``` +https://{YOUR_DOMAIN}/tabs/test +``` +and +``` +brk-multihub://{your_domain} +``` + +### 3. API permissions + +Under **API permissions → Add a permission → Microsoft Graph → Delegated**: + +| Permission | Required for | +|---|---| +| `User.Read` | Who Am I | +| `Presence.ReadWrite` | Toggle Presence | + +--- + +## Manifest + +**`webApplicationInfo`** — required for SSO (`authentication.getAuthToken()` and MSAL silent auth): + +```json +"webApplicationInfo": { + "id": "{YOUR_CLIENT_ID}", + "resource": "api://{YOUR_CLIENT_ID}" +} +``` + +**`staticTabs`**: + +```json +"staticTabs": [ + { + "entityId": "tab", + "name": "Tab", + "contentUrl": "https://{YOUR_DOMAIN}/tabs/test", + "websiteUrl": "https://{YOUR_DOMAIN}/tabs/test", + "scopes": ["personal"] + } +] +``` + +--- + +## Configuration + +**`launchSettings.json`** (or environment variables): + +```json +"AzureAD__TenantId": "{YOUR_TENANT_ID}", +"AzureAD__ClientId": "{YOUR_CLIENT_ID}", +"AzureAD__ClientCredentials__0__SourceType": "ClientSecret", +"AzureAd__ClientCredentials__0__ClientSecret": "{YOUR_CLIENT_SECRET}" +``` + +**`Web/.env`**: + +``` +VITE_CLIENT_ID={YOUR_CLIENT_ID} +``` + +--- + +## Build & Run + +```bash +# Build the React app +cd Web && npm install && npm run build + +# Run the bot +dotnet run +``` diff --git a/core/samples/TabApp/TabApp.csproj b/core/samples/TabApp/TabApp.csproj new file mode 100644 index 00000000..05793d5c --- /dev/null +++ b/core/samples/TabApp/TabApp.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/core/samples/TabApp/Web/index.html b/core/samples/TabApp/Web/index.html new file mode 100644 index 00000000..192d27c2 --- /dev/null +++ b/core/samples/TabApp/Web/index.html @@ -0,0 +1,12 @@ + + + + + + Teams Tab + + +
+ + + diff --git a/core/samples/TabApp/Web/package-lock.json b/core/samples/TabApp/Web/package-lock.json new file mode 100644 index 00000000..4a47acc8 --- /dev/null +++ b/core/samples/TabApp/Web/package-lock.json @@ -0,0 +1,1897 @@ +{ + "name": "tabsapp-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tabsapp-web", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/teams-js": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@microsoft/teams-js/-/teams-js-2.48.1.tgz", + "integrity": "sha512-zL+DzftBSfLnC2r8MK3DdzQBxsbCQcxvHpTO+AkSpxNQw+UD/bpEA1mzhs2r3fqjocjlOLWsSjY8yveNLPUEEA==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "debug": "^4.3.3" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/core/samples/TabApp/Web/package.json b/core/samples/TabApp/Web/package.json new file mode 100644 index 00000000..66ecefaa --- /dev/null +++ b/core/samples/TabApp/Web/package.json @@ -0,0 +1,22 @@ +{ + "name": "tabsapp-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/core/samples/TabApp/Web/src/App.css b/core/samples/TabApp/Web/src/App.css new file mode 100644 index 00000000..ec308897 --- /dev/null +++ b/core/samples/TabApp/Web/src/App.css @@ -0,0 +1,161 @@ +/* ─── Design tokens ──────────────────────────────────────────────────────── */ +:root { + --bg: #f5f5f5; /* page background */ + --surface: #ffffff; /* card / elevated surface */ + --text: #111111; + --accent: #6264a7; /* Teams purple */ + --accent-hover: #4f50a0; + --border: #e0e0e0; + --hint: #666; /* secondary / helper text */ +} + +/* ─── OS-level dark mode ─────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; + } +} + +/* ─── Teams theme override ───────────────────────────────────────────────── + Teams injects a data-theme attribute on ("default", "dark", + "contrast"). */ +[data-theme='dark'], +[data-theme='contrast'] { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; +} + +/* ─── Reset ──────────────────────────────────────────────────────────────── */ +* { + box-sizing: border-box; +} + +/* ─── Base ───────────────────────────────────────────────────────────────── + Segoe UI is the Teams typeface; the stack falls back gracefully on other + platforms. */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +/* ─── Page layout ────────────────────────────────────────────────────────── + Constrain content width and centre it so the tab reads well on wide + desktop clients. */ +.app { + max-width: 680px; + margin: 0 auto; + padding: 24px 16px; +} + +h1 { + font-size: 1.4rem; + color: var(--accent); + margin: 0 0 20px; +} + +/* ─── Card ───────────────────────────────────────────────────────────────── + Each functional section (post-to-chat, who-am-i, …) lives in a card so + they're visually separated without hard dividers. */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 14px; +} + +.card h2 { + margin: 0 0 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ─── Helper text ────────────────────────────────────────────────────────── + Short description shown below a card heading. */ +.hint { + margin: 0 0 12px; + font-size: 0.82rem; + color: var(--hint); +} + +/* ─── JSON output ────────────────────────────────────────────────────────── + Used inside .result cards to display raw server responses. */ +pre { + background: var(--bg); + border-radius: 4px; + padding: 10px; + font-size: 0.8rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +/* ─── Text input ─────────────────────────────────────────────────────────── + Full-width so it stays aligned with the button below it. */ +input { + display: block; + width: 100%; + padding: 8px 10px; + margin-bottom: 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.9rem; +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ─── Button ─────────────────────────────────────────────────────────────── + Teams-purple fill; transitions smoothly on hover. */ +button { + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + padding: 8px 18px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: background 0.15s; +} + +button:hover { + background: var(--accent-hover); +} + +/* ─── Loading state ──────────────────────────────────────────────────────── + Shown while Teams SDK initialises (before app.initialize() resolves). */ +.loading { + padding: 60px; + text-align: center; + color: var(--hint); +} + +/* ─── Result card ────────────────────────────────────────────────────────── + Accent-coloured border makes the response stand out from regular cards. */ +.result pre { + border: 1px solid var(--accent); +} diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx new file mode 100644 index 00000000..76080247 --- /dev/null +++ b/core/samples/TabApp/Web/src/App.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect, useCallback } from 'react' +import { app } from '@microsoft/teams-js' +import { createNestablePublicClientApplication, InteractionRequiredAuthError, IPublicClientApplication } from '@azure/msal-browser' + +const clientId = import.meta.env.VITE_CLIENT_ID as string +let _msal: IPublicClientApplication + +//TODO : do we want to take dependency on teams.client +async function getMsal(): Promise { + if (!_msal) { + _msal = await createNestablePublicClientApplication({ + auth: { clientId, authority: '', redirectUri: '/' }, + }) + } + return _msal +} + +async function acquireToken(scopes: string[], context: app.Context | null): Promise { + const loginHint = context?.user?.loginHint + const msal = await getMsal() + + const accounts = msal.getAllAccounts() + const account = loginHint + ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) + : accounts[0] + + try { + if (!account) throw new InteractionRequiredAuthError('no_account') + const result = await msal.acquireTokenSilent({ scopes, account }) + return result.accessToken + } catch (e) { + if (!(e instanceof InteractionRequiredAuthError)) throw e + const result = await msal.acquireTokenPopup({ scopes, loginHint }) + return result.accessToken + } +} + +export default function App() { + const [context, setContext] = useState(null) + const [message, setMessage] = useState('Hello from the tab!') + const [result, setResult] = useState('') + const [initialized, setInitialized] = useState(false) + const [status, setStatus] = useState(false) + + useEffect(() => { + app.initialize().then(() => { + app.getContext().then((ctx) => { + setContext(ctx) + setInitialized(true) + }) + }) + }, []) + + async function callFunction(name: string, body: unknown): Promise { + const msal = await getMsal() + const { accessToken } = await msal.acquireTokenSilent({ scopes: [`api://${clientId}/access_as_user`] }) + + const res = await fetch(`/functions/${name}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + } + + async function run(fn: () => Promise) { + try { + const res = await fn() + setResult(JSON.stringify(res, null, 2)) + } catch (e) { + setResult(String(e)) + } + } + + const showContext = useCallback(() => run(async () => context), [context]) + const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message, chatId: context?.chat?.id, channelId: context?.channel?.id })), [message, context]) + const whoAmI = useCallback(() => run(async () => { + const accessToken = await acquireToken(['User.Read'], context) + return fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(r => r.json()) + }), [context]) + + const toggleStatus = useCallback(() => run(async () => { + const accessToken = await acquireToken(['Presence.ReadWrite'], context) + + const presenceRes = await fetch('https://graph.microsoft.com/v1.0/me/presence', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!presenceRes.ok) throw new Error(`Graph ${presenceRes.status}`) + const { availability: current } = await presenceRes.json() + + const isAvailable = current === 'Available' + const availability = isAvailable ? 'DoNotDisturb' : 'Available' + const activity = isAvailable ? 'DoNotDisturb' : 'Available' + + const res = await fetch('https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence', { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ availability, activity }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(`Graph ${res.status}: ${JSON.stringify(body)}`) + } + setStatus(availability === 'DoNotDisturb') + return { availability, activity } + }), [context]) + + if (!initialized) { + return
Initializing Teams SDK…
+ } + + return ( +
+

Teams Tab Sample

+ +
+

Teams Context

+

Shows the raw Teams context for this session.

+ +
+ +
+

Post to Chat

+

Sends a proactive message via the bot.

+ setMessage(e.target.value)} + placeholder="Message text" + /> + +
+ +
+

Who Am I

+

Looks up your member record.

+ +
+ +
+

Toggle Presence

+

Sets your Teams presence via Graph. Current: {status ? 'DoNotDisturb' : 'Available'}

+ +
+ + {result && ( +
+

Result

+
{result}
+
+ )} +
+ ) +} diff --git a/core/samples/TabApp/Web/src/main.tsx b/core/samples/TabApp/Web/src/main.tsx new file mode 100644 index 00000000..df579560 --- /dev/null +++ b/core/samples/TabApp/Web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/core/samples/TabApp/Web/tsconfig.json b/core/samples/TabApp/Web/tsconfig.json new file mode 100644 index 00000000..21cb8814 --- /dev/null +++ b/core/samples/TabApp/Web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/core/samples/TabApp/Web/vite.config.ts b/core/samples/TabApp/Web/vite.config.ts new file mode 100644 index 00000000..3ed0f3db --- /dev/null +++ b/core/samples/TabApp/Web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + // Must match the tab name passed to app.WithTab("test", ...) + base: '/tabs/test', + build: { + outDir: 'bin', + emptyOutDir: true, + }, +}) diff --git a/core/samples/TabApp/appsettings.json b/core/samples/TabApp/appsettings.json new file mode 100644 index 00000000..5ebb41d0 --- /dev/null +++ b/core/samples/TabApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/TeamsBot/Cards.cs b/core/samples/TeamsBot/Cards.cs new file mode 100644 index 00000000..173692b0 --- /dev/null +++ b/core/samples/TeamsBot/Cards.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace TeamsBot; + +internal class Cards +{ + public static object ResponseCard(string? feedback) => new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Form Submitted Successfully! ✓", + ["weight"] = "Bolder", + ["size"] = "Large", + ["color"] = "Good" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"You entered: **{feedback ?? "(empty)"}**", + ["wrap"] = true + } + } + }; + + public static object ReactionsCard(string? reactionsAdded, string? reactionsRemoved) => new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Reaction Received", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Reactions Added: {reactionsAdded ?? "(empty)"}", + ["wrap"] = true + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Reactions Removed: {reactionsRemoved ?? "(empty)"}", + ["wrap"] = true + } + } + }; + + public static readonly object FeedbackCardObj = new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Please provide your feedback:", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "Input.Text", + ["id"] = "feedback", + ["placeholder"] = "Enter your feedback here", + ["isMultiline"] = true + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Execute", + ["title"] = "Submit Feedback" + } + } + }; +} diff --git a/core/samples/TeamsBot/GlobalSuppressions.cs b/core/samples/TeamsBot/GlobalSuppressions.cs new file mode 100644 index 00000000..134f3be4 --- /dev/null +++ b/core/samples/TeamsBot/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "")] diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs new file mode 100644 index 00000000..57ab1a6a --- /dev/null +++ b/core/samples/TeamsBot/Program.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using TeamsBot; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +// ==================== MESSAGE HANDLERS ==================== + +// Pattern-based handler: matches "hello" (case-insensitive) +teamsApp.OnMessage("(?i)hello", async (context, cancellationToken) => +{ + await context.SendActivityAsync("Hi there! 👋 You said hello!", cancellationToken); +}); + +// Markdown handler: matches "markdown" (case-insensitive) +teamsApp.OnMessage("(?i)markdown", async (context, cancellationToken) => +{ + MessageActivity markdownMessage = new(""" +# Markdown Examples + +Here are some **markdown** formatting examples: + +## Text Formatting +- **Bold text** +- *Italic text* +- ~~Strikethrough~~ +- `inline code` + +## Lists +1. First item +2. Second item +3. Third item + +## Code Block +```csharp +public class Example +{ + public string Name { get; set; } +} +``` + +## Links +[Visit Microsoft](https://www.microsoft.com) + +## Quotes +> This is a blockquote +> It can span multiple lines +""") + { + TextFormat = TextFormats.Markdown + }; + + await context.SendActivityAsync(markdownMessage, cancellationToken); +}); + +// Citation handler: matches "citation" (case-insensitive) +teamsApp.OnMessage("(?i)citation", async (context, cancellationToken) => +{ + MessageActivity reply = new("Here is a response with citations [1] [2].") + { + TextFormat = TextFormats.Markdown + }; + + reply.AddCitation(1, new CitationAppearance() + { + Name = "Teams SDK Documentation", + Abstract = "The Teams Bot SDK provides a streamlined way to build bots for Microsoft Teams.", + Url = new Uri("https://github.com/nicoco007/microsoft/teams.net"), + Icon = CitationIcon.Text, + EncodingFormat = EncodingFormats.AdaptiveCard + }); + + reply.AddCitation(2, new CitationAppearance() + { + Name = "Bot Framework Overview", + Abstract = "Build intelligent bots that interact naturally with users on Teams.", + Keywords = ["bot", "framework"] + }); + + reply.AddAIGenerated(); + reply.AddFeedback(); + + await context.SendActivityAsync(reply, cancellationToken); +}); + +// Regex-based handler: matches commands starting with "/" +Regex commandRegex = Regexes.CommandRegex(); +teamsApp.OnMessage(commandRegex, async (context, cancellationToken) => +{ + Match match = commandRegex.Match(context.Activity.Text ?? ""); + if (match.Success) + { + string command = match.Groups[1].Value; + string args = match.Groups[2].Value.Trim(); + + string response = command.ToLower() switch + { + "help" => "Available commands: /help, /about, /time", + "about" => "I'm a Teams bot built with the Microsoft Teams Bot SDK!", + "time" => $"Current server time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", + _ => $"Unknown command: /{command}. Type /help for available commands." + }; + + await context.SendActivityAsync(response, cancellationToken); + } +}); + +teamsApp.OnMessageUpdate(async (context, cancellationToken) => +{ + string updatedText = context.Activity.Text ?? ""; + MessageActivity reply = new($"I saw that you updated your message to: `{updatedText}`"); + await context.SendActivityAsync(reply, cancellationToken); +}); + +teamsApp.OnMessage(async (context, cancellationToken) => +{ + await context.SendTypingActivityAsync(cancellationToken); + + string replyText = $"You sent: `{context.Activity.Text}` in activity of type `{context.Activity.Type}`."; + + MessageActivity reply = new(replyText); + reply.AddMention(context.Activity.From!); + + await context.SendActivityAsync(reply, cancellationToken); + + TeamsAttachment feedbackCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.FeedbackCardObj) + .Build(); + MessageActivity feedbackActivity = new([feedbackCard]); + await context.SendActivityAsync(feedbackActivity, cancellationToken); +}); + +teamsApp.OnMessageReaction(async (context, cancellationToken) => +{ + string reactionsAdded = string.Join(", ", context.Activity.ReactionsAdded?.Select(r => r.Type) ?? []); + string reactionsRemoved = string.Join(", ", context.Activity.ReactionsRemoved?.Select(r => r.Type) ?? []); + + TeamsAttachment reactionsCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ReactionsCard(reactionsAdded, reactionsRemoved)) + .Build(); + MessageActivity reply = new([reactionsCard]); + + await context.SendActivityAsync(reply, cancellationToken); +}); + +teamsApp.OnMessageDelete(async (context, cancellationToken) => +{ + + await context.SendActivityAsync("I saw that message you deleted", cancellationToken); +}); + +// ==================== INVOKE ==================== + +teamsApp.OnInvoke(async (context, cancellationToken) => +{ + JsonNode? valueNode = context.Activity.Value; + string? feedbackValue = valueNode?["action"]?["data"]?["feedback"]?.GetValue(); + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithAttachment(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ResponseCard(feedbackValue)) + .Build() + ) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("Invokes are great!!"); +}); + +// ==================== EVENT HANDLERS ==================== + +teamsApp.OnEvent(async (context, cancellationToken) => +{ + Console.WriteLine($"[Event] Name: {context.Activity.Name}"); + await context.SendActivityAsync($"Received event: `{context.Activity.Name}`", cancellationToken); +}); + +// ==================== CONVERSATION UPDATE HANDLERS ==================== + +teamsApp.OnMembersAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[MembersAdded] {context.Activity.MembersAdded?.Count ?? 0} member(s) added"); + + string memberNames = string.Join(", ", context.Activity.MembersAdded?.Select(m => m.Name ?? m.Id) ?? []); + await context.SendActivityAsync($"Welcome! Members added: {memberNames}", cancellationToken); +}); + +teamsApp.OnMembersRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[MembersRemoved] {context.Activity.MembersRemoved?.Count ?? 0} member(s) removed"); + + string memberNames = string.Join(", ", context.Activity.MembersRemoved?.Select(m => m.Name ?? m.Id) ?? []); + await context.SendActivityAsync($"Goodbye! Members removed: {memberNames}", cancellationToken); +}); + +// ==================== INSTALL UPDATE HANDLERS ==================== + +teamsApp.OnInstallUpdate(async (context, cancellationToken) => +{ + string action = context.Activity.Action ?? "unknown"; + Console.WriteLine($"[InstallUpdate] Installation action: {action}"); + + if (context.Activity.Action != InstallUpdateActions.Remove) + { + await context.SendActivityAsync($"Installation update: {action}", cancellationToken); + } +}); + +teamsApp.OnInstall(async (context, cancellationToken) => +{ + Console.WriteLine($"[InstallAdd] Bot was installed"); + await context.SendActivityAsync("Thanks for installing me! I'm ready to help.", cancellationToken); +}); + +teamsApp.OnUnInstall((context, cancellationToken) => +{ + Console.WriteLine($"[InstallRemove] Bot was uninstalled"); + return Task.CompletedTask; +}); + +webApp.Run(); + +partial class Regexes +{ + [GeneratedRegex(@"^/(\w+)(.*)$")] + public static partial Regex CommandRegex(); +} diff --git a/core/samples/TeamsBot/TeamsBot.csproj b/core/samples/TeamsBot/TeamsBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/TeamsBot/TeamsBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/TeamsBot/appsettings.json b/core/samples/TeamsBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/TeamsBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/TeamsChannelBot/Program.cs b/core/samples/TeamsChannelBot/Program.cs new file mode 100644 index 00000000..bde0f85c --- /dev/null +++ b/core/samples/TeamsChannelBot/Program.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication app = webApp.UseTeamsBotApplication(); + + +//TODO : implement next(); +/*app.OnConversationUpdate(async (context, cancellationToken) => +{ + Console.WriteLine($"[ConversationUpdate] Conversation updated"); +} +); +;*/ + +// ==================== CHANNEL EVENT HANDLERS ==================== + +app.OnChannelCreated(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelCreated] Channel '{channelName}' was created"); + await context.SendActivityAsync($"New channel created: {channelName}", cancellationToken); +}); + +app.OnChannelDeleted(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelDeleted] Channel '{channelName}' was deleted"); + await context.SendActivityAsync($"Channel deleted: {channelName}", cancellationToken); +}); + +app.OnChannelRenamed(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelRenamed] Channel renamed to '{channelName}'"); + await context.SendActivityAsync($"Channel renamed to: {channelName}", cancellationToken); +}); + +/* +//not able to test - no activity received +app.OnChannelRestored(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelRestored] Channel '{channelName}' was restored"); + await context.SendActivityAsync($"Channel restored: {channelName}", cancellationToken); +}); + +// not able to test - can't add bot to shared channel +app.OnChannelShared(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelShared] Channel '{channelName}' was shared"); + await context.SendActivityAsync($"Channel shared: {channelName}", cancellationToken); +}); + +// not able to test - can't add bot to shared channel +app.OnChannelUnshared(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelUnshared] Channel '{channelName}' was unshared"); + await context.SendActivityAsync($"Channel unshared: {channelName}", cancellationToken); +}); + +// not able to test - can't add bot to private/shared channel +app.OnChannelMemberAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[ChannelMemberAdded] Member added to channel"); + await context.SendActivityAsync("A member was added to the channel", cancellationToken); +}); + +// not able to test - can't add bot to private/shared channel +app.OnChannelMemberRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[ChannelMemberRemoved] Member removed from channel"); + await context.SendActivityAsync("A member was removed from the channel", cancellationToken); +}); +*/ + +// ==================== TEAM EVENT HANDLERS ==================== + +app.OnTeamMemberAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[TeamMemberAdded] Member added to team"); + await context.SendActivityAsync("A member was added to the team", cancellationToken); +}); + +app.OnTeamMemberRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[TeamMemberRemoved] Member removed from team"); + await context.SendActivityAsync("A member was removed from the team", cancellationToken); +}); + +app.OnTeamArchived((context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamArchived] Team '{teamName}' was archived"); + return Task.CompletedTask; +}); + +app.OnTeamDeleted((context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamDeleted] Team '{teamName}' was deleted"); + return Task.CompletedTask; +}); + +app.OnTeamRenamed(async (context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamRenamed] Team renamed to '{teamName}'"); + await context.SendActivityAsync($"Team renamed to: {teamName}", cancellationToken); +}); + +app.OnTeamUnarchived(async (context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamUnarchived] Team '{teamName}' was unarchived"); + await context.SendActivityAsync($"Team unarchived: {teamName}", cancellationToken); +}); +/* +// how to test ? +app.OnTeamHardDeleted((context, cancellationToken) => +{ + var teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamHardDeleted] Team '{teamName}' was permanently deleted"); + return Task.CompletedTask; +}); + +// how to test ? Restore is unarchived +app.OnTeamRestored(async (context, cancellationToken) => +{ + var teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamRestored] Team '{teamName}' was restored"); + await context.SendActivityAsync($"Team restored: {teamName}", cancellationToken); +}); +*/ + +webApp.Run(); diff --git a/core/samples/TeamsChannelBot/README.md b/core/samples/TeamsChannelBot/README.md new file mode 100644 index 00000000..7ae59db3 --- /dev/null +++ b/core/samples/TeamsChannelBot/README.md @@ -0,0 +1,53 @@ +# ConversationSample + +This sample demonstrates all **ConversationUpdate** and **InstallUpdate** activity handlers available in the Teams Bot framework. + +## Handlers Demonstrated + +### ConversationUpdate Handlers + +#### General Handlers +- **OnConversationUpdate** - Catches all conversation update activities +- **OnMembersAdded** - Triggered when members are added to a conversation +- **OnMembersRemoved** - Triggered when members are removed from a conversation + +#### Channel Event Handlers +- **OnChannelCreated** - Channel is created in a team +- **OnChannelDeleted** - Channel is deleted from a team +- **OnChannelRenamed** - Channel name is changed +- **OnChannelRestored** - Deleted channel is restored +- **OnChannelShared** - Channel is shared with another team +- **OnChannelUnshared** - Channel sharing is removed +- **OnChannelMemberAdded** - Member is added to a specific channel +- **OnChannelMemberRemoved** - Member is removed from a specific channel + +#### Team Event Handlers +- **OnTeamArchived** - Team is archived +- **OnTeamDeleted** - Team is soft-deleted +- **OnTeamHardDeleted** - Team is permanently deleted +- **OnTeamRenamed** - Team name is changed +- **OnTeamRestored** - Deleted team is restored +- **OnTeamUnarchived** - Archived team is unarchived + +### InstallUpdate Handlers +- **OnInstallUpdate** - Catches all installation update activities +- **OnInstallAdd** - Bot is installed to a team/chat +- **OnInstallRemove** - Bot is uninstalled from a team/chat + +## Running the Sample + +1. Build and run the project: + ```bash + dotnet run --project samples/ConversationSample/ConversationSample.csproj + ``` + +2. Configure your bot in the Teams Developer Portal or Bot Framework portal to point to `http://localhost:3978/api/messages` + +3. Install the bot in a Teams team or chat to trigger the various conversation and installation events + +## Notes + +- Each handler logs to the console when triggered +- Most handlers send a confirmation message back to the conversation +- The `OnInstallRemove` handler typically cannot send messages (bot is being removed) +- Channel and Team event handlers require the activity's `ChannelData.EventType` to be set appropriately diff --git a/core/samples/TeamsChannelBot/TeamsChannelBot.csproj b/core/samples/TeamsChannelBot/TeamsChannelBot.csproj new file mode 100644 index 00000000..f30bcbe3 --- /dev/null +++ b/core/samples/TeamsChannelBot/TeamsChannelBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/TeamsChannelBot/appsettings.json b/core/samples/TeamsChannelBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/TeamsChannelBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/scenarios/Properties/launchSettings.example.json b/core/samples/scenarios/Properties/launchSettings.example.json new file mode 100644 index 00000000..5ef4cff2 --- /dev/null +++ b/core/samples/scenarios/Properties/launchSettings.example.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "ridobotlocal": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development", + "AzureAd__Instance": "https://login.microsoftonline.com/", + "AzureAd__ClientId": "", + "AzureAd__TenantId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "", + "Logging__LogLevel__Default": "Warning", + "Logging__LogLevel__Microsoft.Bot": "Information", + } + } + } +} diff --git a/core/samples/scenarios/hello-assistant.cs b/core/samples/scenarios/hello-assistant.cs new file mode 100644 index 00000000..eac282ce --- /dev/null +++ b/core/samples/scenarios/hello-assistant.cs @@ -0,0 +1,34 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Web + +#:project ../../src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj +#:project ../../src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj + + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Apps.Schema.Entities; + +var builder = TeamsBotApplication.CreateBuilder(); +var teamsApp = builder.Build(); + +teamsApp.OnMessage = async (messageArgs, context, cancellationToken) => +{ + string replyText = $"You sent: `{messageArgs.Text}` in activity of type `{context.Activity.Type}`."; + + // await context.SendTypingActivityAsync(cancellationToken); + + // TeamsActivity reply = TeamsActivity.CreateBuilder() + // .WithType(TeamsActivityType.Message) + // .WithConversationReference(context.Activity) + // .WithText(replyText) + // .Build(); + + + // reply.AddMention(context.Activity.From!, "ridobotlocal", true); + + await context.SendActivityAsync(replyText, cancellationToken); +}; + +teamsApp.Run(); \ No newline at end of file diff --git a/core/samples/scenarios/middleware.cs b/core/samples/scenarios/middleware.cs new file mode 100755 index 00000000..5a771997 --- /dev/null +++ b/core/samples/scenarios/middleware.cs @@ -0,0 +1,39 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Web + +#:project ../../src/Microsoft.Bot.Core/Microsoft.Bot.Core.csproj + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Teams.Bot.Core.Hosting; + + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); +var botApp = webApp.UseBotApplication(); + +botApp.Use(new MyTurnMiddleWare()); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + string? text = activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + var replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(activity) + .WithProperty("text", "You said " + text) + .Build(); + await botApp.SendActivityAsync(replyActivity, cancellationToken); +}; + +webApp.Run(); + +public class MyTurnMiddleWare : ITurnMiddleWare +{ + public Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn next, CancellationToken cancellationToken = default) + { + Console.WriteLine($"MIDDLEWARE: Processing activity {activity.Type} {activity.Id}"); + return next(cancellationToken); + } +} \ No newline at end of file diff --git a/core/samples/scenarios/proactive.cs b/core/samples/scenarios/proactive.cs new file mode 100644 index 00000000..9728133a --- /dev/null +++ b/core/samples/scenarios/proactive.cs @@ -0,0 +1,43 @@ +#!/usr/bin/dotnet run + +#:sdk Microsoft.NET.Sdk.Worker + +#:project ../../src/Microsoft.Bot.Core/Microsoft.Bot.Core.csproj + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddBotApplicationClients(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); + +public class Worker(ConversationClient conversationClient, ILogger logger) : BackgroundService +{ + const string conversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (logger.IsEnabled(LogLevel.Information)) + { + CoreActivity proactiveMessage = new() + { + Text = $"Proactive hello at {DateTimeOffset.Now}", + ServiceUrl = new Uri("https://smba.trafficmanager.net/amer/56653e9d-2158-46ee-90d7-675c39642038/"), + Conversation = new() + { + Id = conversationId + } + }; + var aid = await conversationClient.SendActivityAsync(proactiveMessage, stoppingToken); + logger.LogInformation($"Activity {aid} sent"); + } + await Task.Delay(1000, stoppingToken); + } + } +} \ No newline at end of file diff --git a/core/src/Directory.Build.props b/core/src/Directory.Build.props new file mode 100644 index 00000000..36f9ef7a --- /dev/null +++ b/core/src/Directory.Build.props @@ -0,0 +1,34 @@ + + + Microsoft Teams SDK + Microsoft + Microsoft + © Microsoft Corporation. All rights reserved. + https://github.com/microsoft/teams.net + git + false + bot_icon.png + README.md + MIT + true + true + true + snupkg + false + + + latest-all + true + true + + + + + + + + all + 3.9.50 + + + diff --git a/core/src/Directory.Build.targets b/core/src/Directory.Build.targets new file mode 100644 index 00000000..7d5f7f8e --- /dev/null +++ b/core/src/Directory.Build.targets @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/Microsoft.Teams.Bot.Apps/Context.cs b/core/src/Microsoft.Teams.Bot.Apps/Context.cs new file mode 100644 index 00000000..779d7f6a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Context.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Apps; + +// TODO: Make Context Generic over the TeamsActivity type. +// It should be able to work with any type of TeamsActivity. + + +/// +/// Context for a bot turn. +/// +/// +/// +public class Context(TeamsBotApplication botApplication, TActivity activity) where TActivity : TeamsActivity +{ + /// + /// Base bot application. + /// + public TeamsBotApplication TeamsBotApplication { get; } = botApplication; + + /// + /// Current activity. + /// + public TActivity Activity { get; } = activity; + + /// + /// Sends a message activity as a reply. + /// + /// + /// + /// + public Task SendActivityAsync(string text, CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + new TeamsActivityBuilder() + .WithConversationReference(Activity) + .WithText(text) + .Build(), cancellationToken); + + /// + /// Sends Activity + /// + /// + /// + /// + public Task SendActivityAsync(TeamsActivity activity, CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + new TeamsActivityBuilder(activity) + .WithConversationReference(Activity) + .Build(), cancellationToken); + + + /// + /// Sends a typing activity to the conversation asynchronously. + /// + /// + /// + public Task SendTypingActivityAsync(CancellationToken cancellationToken = default) + => TeamsBotApplication.SendActivityAsync( + TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Typing) + .WithConversationReference(Activity) + .Build(), cancellationToken); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Bot.Apps/GlobalSuppressions.cs new file mode 100644 index 00000000..f401a5f1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/GlobalSuppressions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", + "CA1873:Avoid potentially expensive logging", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Apps")] + +[assembly: SuppressMessage("Performance", + "CA1848:Use the LoggerMessage delegates", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Apps")] + +[assembly: SuppressMessage("Usage", + "CA2227:Collection properties should be read only", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Apps")] diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/AdaptiveCardHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/AdaptiveCardHandler.cs new file mode 100644 index 00000000..dbc1097a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/AdaptiveCardHandler.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling adaptive card action invoke activities. +/// +public delegate Task AdaptiveCardActionHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering adaptive card action invoke handlers. +/// +public static class AdaptiveCardExtensions +{ + /// + /// Registers a handler for adaptive card action invoke activities. + /// + public static TeamsBotApplication OnAdaptiveCardAction(this TeamsBotApplication app, AdaptiveCardActionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.AdaptiveCardAction), + Selector = activity => activity.Name == InvokeNames.AdaptiveCardAction, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs new file mode 100644 index 00000000..ad69b8f7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/ConversationUpdateHandler.cs @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling conversation update activities. +/// +/// +/// +/// +public delegate Task ConversationUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering conversation update activity handlers. +/// +public static class ConversationUpdateExtensions +{ + /// + /// Registers a handler for conversation update activities. + /// + /// + /// + /// + public static TeamsBotApplication OnConversationUpdate(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.ConversationUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for conversation update activities where members were added. + /// + /// + /// + /// + public static TeamsBotApplication OnMembersAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, "membersAdded"]), + Selector = activity => activity.MembersAdded?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for conversation update activities where members were removed. + /// + /// + /// + /// + public static TeamsBotApplication OnMembersRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, "membersRemoved"]), + Selector = activity => activity.MembersRemoved?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + // Channel Event Handlers + + /// + /// Registers a handler for channel created events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelCreated(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelCreated]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelCreated, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel deleted events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel renamed events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelRenamed(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelRenamed]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelRenamed, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// + /// Registers a handler for channel restored events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelRestored(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelRestored]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelRestored, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel shared events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelShared(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelShared]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelShared, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel unshared events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelUnshared(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelUnShared]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelUnShared, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel member added events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelMemberAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelMemberAdded]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelMemberAdded, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel member removed events. + /// + /// + /// + /// + public static TeamsBotApplication OnChannelMemberRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelMemberRemoved]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelMemberRemoved, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ + + // Team Event Handlers + + /// + /// Registers a handler for team member added events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamMemberAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamMemberAdded]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamMemberAdded, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team member removed events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamMemberRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamMemberRemoved]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamMemberRemoved, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team archived events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamArchived(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamArchived]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamArchived, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team deleted events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team renamed events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamRenamed(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamRenamed]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamRenamed, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team unarchived events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamUnarchived(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamUnarchived]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamUnarchived, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// Registers a handler for team restored events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamRestored(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamRestored]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamRestored, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team hard deleted events. + /// + /// + /// + /// + public static TeamsBotApplication OnTeamHardDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamHardDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamHardDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/EventHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/EventHandler.cs new file mode 100644 index 00000000..4fec727e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/EventHandler.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling any event activity. +/// +public delegate Task EventActivityHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering generic event activity handlers. +/// +public static class EventExtensions +{ + /// + /// Registers a handler for all event activities. + /// + public static TeamsBotApplication OnEvent(this TeamsBotApplication app, EventActivityHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.Event, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// + /// Registers a handler for read receipt event activities. + /// Fired by Teams when a user reads a message sent by the bot in a 1:1 chat. + /// No value payload — the event itself is the notification. + /// + public static TeamsBotApplication OnReadReceipt(this TeamsBotApplication app, EventActivityHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.ReadReceipt), + Selector = activity => activity.Name == EventNames.ReadReceipt, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/FileConsentHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/FileConsentHandler.cs new file mode 100644 index 00000000..4c215e01 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/FileConsentHandler.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling file consent invoke activities. +/// +public delegate Task> FileConsentValueHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering file consent invoke handlers. +/// +public static class FileConsentExtensions +{ + + /// + /// Registers a handler for file consent invoke activities. + /// + public static TeamsBotApplication OnFileConsent(this TeamsBotApplication app, FileConsentValueHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.FileConsent), + Selector = activity => activity.Name == InvokeNames.FileConsent, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallUpdateHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallUpdateHandler.cs new file mode 100644 index 00000000..c97b4a60 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InstallUpdateHandler.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling installation update activities. +/// +/// +/// +/// +public delegate Task InstallUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering installation update activity handlers. +/// +public static class InstallUpdateExtensions +{ + /// + /// Registers a handler for installation update activities. + /// + /// + /// + /// + public static TeamsBotApplication OnInstallUpdate(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.InstallationUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for installation add activities. + /// + /// + /// + /// + public static TeamsBotApplication OnInstall(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.InstallationUpdate, InstallUpdateActions.Add]), + Selector = activity => activity.Action == InstallUpdateActions.Add, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for installation remove activities. + /// + /// + /// + /// + public static TeamsBotApplication OnUnInstall(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.InstallationUpdate, InstallUpdateActions.Remove]), + Selector = activity => activity.Action == InstallUpdateActions.Remove, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs new file mode 100644 index 00000000..0f95c820 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/InvokeHandler.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Represents a method that handles an invocation request and returns a response asynchronously. +/// +/// The context for the invocation, containing request data and metadata required to process the operation. Cannot be +/// null. +/// A cancellation token that can be used to cancel the operation. The default value is . +/// A task that represents the asynchronous operation. The task result contains the response to the invocation. +public delegate Task InvokeHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Provides extension methods for registering handlers for invoke activities in a Teams bot application. +/// +public static class InvokeExtensions +{ + /// + /// Registers a handler for invoke activities. + /// + /// The Teams bot application. + /// The invoke handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnInvoke(this TeamsBotApplication app, InvokeHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.Invoke, + Selector = _ => true, + HandlerWithReturn = async (ctx, cancellationToken) => + { + return await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MeetingHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MeetingHandler.cs new file mode 100644 index 00000000..4598f42d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MeetingHandler.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling meeting start event activities. +/// +public delegate Task MeetingStartHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting end event activities. +/// +public delegate Task MeetingEndHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting participant join event activities. +/// +public delegate Task MeetingParticipantJoinHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting participant leave event activities. +/// +public delegate Task MeetingParticipantLeaveHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering meeting event activity handlers. +/// +public static class MeetingExtensions +{ + /// + /// Registers a handler for meeting start event activities. + /// + public static TeamsBotApplication OnMeetingStart(this TeamsBotApplication app, MeetingStartHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingStart), + Selector = activity => activity.Name == EventNames.MeetingStart, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting end event activities. + /// + public static TeamsBotApplication OnMeetingEnd(this TeamsBotApplication app, MeetingEndHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingEnd), + Selector = activity => activity.Name == EventNames.MeetingEnd, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting participant join event activities. + /// + public static TeamsBotApplication OnMeetingParticipantJoin(this TeamsBotApplication app, MeetingParticipantJoinHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingParticipantJoin), + Selector = activity => activity.Name == EventNames.MeetingParticipantJoin, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting participant leave event activities. + /// + public static TeamsBotApplication OnMeetingParticipantLeave(this TeamsBotApplication app, MeetingParticipantLeaveHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingParticipantLeave), + Selector = activity => activity.Name == EventNames.MeetingParticipantLeave, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageDeleteHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageDeleteHandler.cs new file mode 100644 index 00000000..a140491a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageDeleteHandler.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message delete activities. +/// +/// +/// +/// +public delegate Task MessageDeleteHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message delete activity handlers. +/// +public static class MessageDeleteExtensions +{ + /// + /// Registers a handler for message delete activities. + /// + /// + /// + /// + public static TeamsBotApplication OnMessageDelete(this TeamsBotApplication app, MessageDeleteHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageDelete, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageExtensionHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageExtensionHandler.cs new file mode 100644 index 00000000..24d0c8d2 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageExtensionHandler.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message extension query invoke activities. +/// +public delegate Task> MessageExtensionQueryHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension submit action invoke activities. +/// Can return either a TaskModuleResponse or MessageExtensionResponse. +/// +public delegate Task> MessageExtensionSubmitActionHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension fetch task invoke activities. +/// Can return either a TaskModuleResponse or MessageExtensionResponse. +/// +public delegate Task> MessageExtensionFetchTaskHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension query link invoke activities. +/// +public delegate Task> MessageExtensionQueryLinkHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension anonymous query link invoke activities. +/// +public delegate Task> MessageExtensionAnonQueryLinkHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension select item invoke activities. +/// +public delegate Task> MessageExtensionSelectItemHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension query setting URL invoke activities. +/// +public delegate Task> MessageExtensionQuerySettingUrlHandler(Context> context, CancellationToken cancellationToken = default); + +/* +/// +/// Delegate for handling message extension card button clicked invoke activities. +/// +public delegate Task MessageExtensionCardButtonClickedHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension setting invoke activities. +/// +public delegate Task MessageExtensionSettingHandler(Context> context, CancellationToken cancellationToken = default); +*/ + +/// +/// Extension methods for registering message extension invoke handlers. +/// +public static class MessageExtensionExtensions +{ + //TODO : add msg ext prefix to handlers ? very confusing right now as we have both onFetchTask and onTaskFetch. + //onSubmitAction is confusing as it is similar to adaptive cards + + /// + /// Registers a handler for message extension query invoke activities with strongly-typed response. + /// + public static TeamsBotApplication OnQuery(this TeamsBotApplication app, MessageExtensionQueryHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQuery), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQuery, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension submit action invoke activities. + /// + public static TeamsBotApplication OnSubmitAction(this TeamsBotApplication app, MessageExtensionSubmitActionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSubmitAction), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSubmitAction, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension query link invoke activities with strongly-typed response. + /// + public static TeamsBotApplication OnQueryLink(this TeamsBotApplication app, MessageExtensionQueryLinkHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQueryLink), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQueryLink, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension anonymous query link invoke activities with strongly-typed response. + /// + public static TeamsBotApplication OnAnonQueryLink(this TeamsBotApplication app, MessageExtensionAnonQueryLinkHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionAnonQueryLink), + Selector = activity => activity.Name == InvokeNames.MessageExtensionAnonQueryLink, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension fetch task invoke activities. + /// + public static TeamsBotApplication OnFetchTask(this TeamsBotApplication app, MessageExtensionFetchTaskHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionFetchTask), + Selector = activity => activity.Name == InvokeNames.MessageExtensionFetchTask, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension select item invoke activities with strongly-typed response. + /// + public static TeamsBotApplication OnSelectItem(this TeamsBotApplication app, MessageExtensionSelectItemHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSelectItem), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSelectItem, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension query setting URL invoke activities with strongly-typed response. + /// + public static TeamsBotApplication OnQuerySettingUrl(this TeamsBotApplication app, MessageExtensionQuerySettingUrlHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQuerySettingUrl), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQuerySettingUrl, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + + /* + /// + /// Registers a handler for message extension card button clicked invoke activities. + /// + public static TeamsBotApplication OnCardButtonClicked(this TeamsBotApplication app, MessageExtensionCardButtonClickedHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionCardButtonClicked), + Selector = activity => activity.Name == InvokeNames.MessageExtensionCardButtonClicked, + HandlerWithReturn = async (ctx, cancellationToken) => + { + var typedActivity = new InvokeActivity(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension setting invoke activities. + /// + public static TeamsBotApplication OnSetting(this TeamsBotApplication app, MessageExtensionSettingHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSetting), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSetting, + HandlerWithReturn = async (ctx, cancellationToken) => + { + var typedActivity = new InvokeActivity(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs new file mode 100644 index 00000000..a607c2c7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageHandler.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message activities. +/// +/// +/// +/// +public delegate Task MessageHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message activity handlers. +/// +public static class MessageExtensions +{ + /// + /// Registers a handler for message activities. + /// + /// + /// + /// + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message activities matching the specified pattern. + /// + /// + /// + /// + /// + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, string pattern, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + Regex regex = new(pattern); + + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.Message, pattern]), + Selector = msg => regex.IsMatch(msg.Text ?? ""), + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message activities matching the specified regex. + /// + /// + /// + /// + /// + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, Regex regex, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + ArgumentNullException.ThrowIfNull(regex, nameof(regex)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.Message, regex.ToString()]), + Selector = msg => regex.IsMatch(msg.Text ?? ""), + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs new file mode 100644 index 00000000..86165af6 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageReactionHandler.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message reaction activities. +/// +/// +/// +/// +public delegate Task MessageReactionHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message reaction activity handlers. +/// +public static class MessageReactionExtensions +{ + /// + /// Registers a handler for message reaction activities. + /// + /// + /// + /// + public static TeamsBotApplication OnMessageReaction(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageReaction, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message reaction activities where reactions were added. + /// + /// + /// + /// + public static TeamsBotApplication OnMessageReactionAdded(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.MessageReaction, "reactionsAdded"]), + Selector = activity => activity.ReactionsAdded?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message reaction activities where reactions were removed. + /// + /// + /// + /// + public static TeamsBotApplication OnMessageReactionRemoved(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.MessageReaction, "reactionsRemoved"]), + Selector = activity => activity.ReactionsRemoved?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageUpdateHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageUpdateHandler.cs new file mode 100644 index 00000000..c0e1f975 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/MessageUpdateHandler.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling message update activities. +/// +/// +/// +/// +public delegate Task MessageUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message update activity handlers. +/// +public static class MessageUpdateExtensions +{ + /// + /// Registers a handler for message update activities. + /// + /// + /// + /// + public static TeamsBotApplication OnMessageUpdate(this TeamsBotApplication app, MessageUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Handlers/TaskHandler.cs b/core/src/Microsoft.Teams.Bot.Apps/Handlers/TaskHandler.cs new file mode 100644 index 00000000..a871fbd4 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Handlers/TaskHandler.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Handlers; + +/// +/// Delegate for handling task module invoke activities. +/// +public delegate Task> TaskModuleHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering task module invoke handlers. +/// +public static class TaskExtensions +{ + + /// + /// Registers a handler for task module fetch invoke activities. + /// + public static TeamsBotApplication OnTaskFetch(this TeamsBotApplication app, TaskModuleHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.TaskFetch), + Selector = activity => activity.Name == InvokeNames.TaskFetch, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); ; + } + }); + + return app; + } + + /// + /// Registers a handler for task module submit invoke activities with strongly-typed value and response. + /// + public static TeamsBotApplication OnTaskSubmit(this TeamsBotApplication app, TaskModuleHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.TaskSubmit), + Selector = activity => activity.Name == InvokeNames.TaskSubmit, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj b/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj new file mode 100644 index 00000000..bcc1e6bb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Routing/Route.cs b/core/src/Microsoft.Teams.Bot.Apps/Routing/Route.cs new file mode 100644 index 00000000..65613a66 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Routing/Route.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Routing; + +/// +/// Base class for routes, providing non-generic access to route functionality +/// +public abstract class RouteBase +{ + /// + /// Gets or sets the name of the route + /// + public abstract string Name { get; set; } + + /// + /// Determines if the route matches the given activity + /// + /// + /// + public abstract bool Matches(TeamsActivity activity); + + /// + /// Invokes the route handler if the activity matches the expected type + /// + /// + /// + /// + public abstract Task InvokeRoute(Context ctx, CancellationToken cancellationToken = default); + + /// + /// Invokes the route handler if the activity matches the expected type and returns a response + /// + /// + /// + /// + public abstract Task InvokeRouteWithReturn(Context ctx, CancellationToken cancellationToken = default); +} + +/// +/// Represents a route for handling Teams activities +/// +public class Route : RouteBase where TActivity : TeamsActivity +{ + private string _name = string.Empty; + + /// + /// Gets or sets the name of the route + /// + public override string Name + { + get => _name; + set => _name = value; + } + + /// + /// Predicate function to determine if this route should handle the activity + /// + public Func Selector { get; set; } = _ => true; + + /// + /// Handler function to process the activity + /// + public Func, CancellationToken, Task>? Handler { get; set; } + + /// + /// Handler function to process the activity and return a response + /// + public Func, CancellationToken, Task>? HandlerWithReturn { get; set; } + + /// + /// Determines if the route matches the given activity + /// + /// + /// + public override bool Matches(TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return activity is TActivity && Selector((TActivity)activity); + } + + /// + /// Invokes the route handler if the activity matches the expected type + /// + /// + /// + /// + public override async Task InvokeRoute(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + if (ctx.Activity is TActivity typedActivity) + { + Context typedContext = new(ctx.TeamsBotApplication, typedActivity); + if (Handler is not null) + { + await Handler(typedContext, cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Invokes the route handler if the activity matches the expected type and returns a response + /// + /// + /// + /// + /// + public override async Task InvokeRouteWithReturn(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + if (ctx.Activity is TActivity typedActivity) + { + Context typedContext = new(ctx.TeamsBotApplication, typedActivity); + if (HandlerWithReturn is not null) + { + return await HandlerWithReturn(typedContext, cancellationToken).ConfigureAwait(false); + } + } + return null!; // TODO: throw? + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Bot.Apps/Routing/Router.cs new file mode 100644 index 00000000..5a2b0f9d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Routing/Router.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.Routing; + +/// +/// Router for dispatching Teams activities to registered routes +/// +// TODO : add inline docs to handlers for breaking change +internal sealed class Router +{ + private readonly List _routes = []; + private readonly ILogger _logger; + + internal Router(ILogger logger) + { + _logger = logger; + } + + /// + /// Routes registered in the router. + /// + public IReadOnlyList GetRoutes() => _routes.AsReadOnly(); + + /// + /// Registers a route. Routes are checked and invoked in registration order. + /// For non-invoke activities all matching routes run sequentially. + /// For invoke activities — routes must be non-overlapping. + /// + /// + /// Thrown if a route with the same name is already registered, or if an invoke catch-all + /// is mixed with specific invoke handlers. + /// + public Router Register(Route route) where TActivity : TeamsActivity + { + if (_routes.Any(r => r.Name == route.Name)) + { + throw new InvalidOperationException($"A route with name '{route.Name}' is already registered."); + } + + string invokePrefix = TeamsActivityType.Invoke + "/"; + + if (route.Name == TeamsActivityType.Invoke && _routes.Any(r => r.Name.StartsWith(invokePrefix, StringComparison.Ordinal))) + { + throw new InvalidOperationException("Cannot register a catch-all invoke handler when specific invoke handlers are already registered. Use specific handlers or handle all invoke types inside OnInvoke."); + } + + if (route.Name.StartsWith(invokePrefix, StringComparison.Ordinal) && _routes.Any(r => r.Name == TeamsActivityType.Invoke)) + { + throw new InvalidOperationException($"Cannot register '{route.Name}' when a catch-all invoke handler is already registered. Remove OnInvoke or use specific handlers exclusively."); + } + _routes.Add(route); + return this; + } + + /// + /// Dispatches the activity to all matching routes in registration order. + /// + public async Task DispatchAsync(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + + List matchingRoutes = [.. _routes.Where(r => r.Matches(ctx.Activity))]; + + if (matchingRoutes.Count == 0 && _routes.Count > 0) + { + _logger.LogWarning( + "No routes matched activity of type '{Type}'", + ctx.Activity.Type + ); + return; + } + + foreach (RouteBase route in matchingRoutes) + { + _logger.LogDebug("Dispatching '{Type}' activity to route '{Name}'.", ctx.Activity.Type, route.Name); + await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Dispatches the specified activity context to the first matching route and returns the result of the invocation. + /// + /// The activity context to dispatch. Cannot be null. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a response object with the outcome + /// of the invocation. + public async Task DispatchWithReturnAsync(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + + List matchingRoutes = [.. _routes.Where(r => r.Matches(ctx.Activity))]; + string? name = ctx.Activity is InvokeActivity inv ? inv.Name : null; + + if (matchingRoutes.Count == 0 && _routes.Count > 0) + { + _logger.LogDebug("No routes matched invoke activity with name '{Name}'; handler will not execute.", name); + return null!; // TODO : return appropriate response + } + + _logger.LogDebug("Dispatching invoke activity with name '{Name}' to route '{Route}'", name, matchingRoutes[0].Name); + + return await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false); + } + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/ConversationUpdateActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/ConversationUpdateActivity.cs new file mode 100644 index 00000000..80e0314b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/ConversationUpdateActivity.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a conversation update activity. +/// +public class ConversationUpdateActivity : TeamsActivity +{ + /// + /// Convenience method to create a ConversationUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A ConversationUpdateActivity instance. + public static new ConversationUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new ConversationUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public ConversationUpdateActivity() : base(TeamsActivityType.ConversationUpdate) + { + } + + /// + /// Internal constructor to create ConversationUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected ConversationUpdateActivity(CoreActivity activity) : base(activity) + { + /* + if (activity.Properties.TryGetValue("topicName", out var topicName)) + { + TopicName = topicName?.ToString(); + activity.Properties.Remove("topicName"); + } + + if (activity.Properties.TryGetValue("historyDisclosed", out var historyDisclosed) && historyDisclosed != null) + { + if (historyDisclosed is JsonElement je) + { + if (je.ValueKind == JsonValueKind.True) + HistoryDisclosed = true; + else if (je.ValueKind == JsonValueKind.False) + HistoryDisclosed = false; + } + else if (historyDisclosed is bool boolValue) + { + HistoryDisclosed = boolValue; + } + else if (bool.TryParse(historyDisclosed.ToString(), out var result)) + { + HistoryDisclosed = result; + } + activity.Properties.Remove("historyDisclosed"); + } + */ + + if (activity.Properties.TryGetValue("membersAdded", out object? membersAdded) && membersAdded != null) + { + if (membersAdded is JsonElement je) + { + MembersAdded = JsonSerializer.Deserialize>(je.GetRawText()); + } + else + { + MembersAdded = membersAdded as IList; + } + activity.Properties.Remove("membersAdded"); + } + + if (activity.Properties.TryGetValue("membersRemoved", out object? membersRemoved) && membersRemoved != null) + { + if (membersRemoved is JsonElement je) + { + MembersRemoved = JsonSerializer.Deserialize>(je.GetRawText()); + } + else + { + MembersRemoved = membersRemoved as IList; + } + activity.Properties.Remove("membersRemoved"); + } + } + + //TODO : review properties + /* + /// + /// Gets or sets the updated topic name of the conversation. + /// + [JsonPropertyName("topicName")] + public string? TopicName { get; set; } + + /// + /// Gets or sets a value indicating whether the prior history is disclosed. + /// + [JsonPropertyName("historyDisclosed")] + public bool? HistoryDisclosed { get; set; } + */ + + /// + /// Gets or sets the collection of members added to the conversation. + /// + [JsonPropertyName("membersAdded")] + public IList? MembersAdded { get; set; } + + /// + /// Gets or sets the collection of members removed from the conversation. + /// + [JsonPropertyName("membersRemoved")] + public IList? MembersRemoved { get; set; } +} + +/// +/// String constants for conversation event types. +/// +public static class ConversationEventTypes +{ + /// + /// Channel created event. + /// + public const string ChannelCreated = "channelCreated"; + + /// + /// Channel deleted event. + /// + public const string ChannelDeleted = "channelDeleted"; + + /// + /// Channel renamed event. + /// + public const string ChannelRenamed = "channelRenamed"; + + //TODO : review these events + /* + /// + /// Channel restored event. + /// + public const string ChannelRestored = "channelRestored"; + + /// + /// Channel shared event. + /// + public const string ChannelShared = "channelShared"; + + /// + /// Channel unshared event. + /// + public const string ChannelUnShared = "channelUnShared"; + + /// + /// Channel member added event. + /// + public const string ChannelMemberAdded = "channelMemberAdded"; + + /// + /// Channel member removed event. + /// + public const string ChannelMemberRemoved = "channelMemberRemoved"; + */ + + /// + /// Team member added event. + /// + public const string TeamMemberAdded = "teamMemberAdded"; + + /// + /// Team member removed event. + /// + public const string TeamMemberRemoved = "teamMemberRemoved"; + + /// + /// Team archived event. + /// + public const string TeamArchived = "teamArchived"; + + /// + /// Team deleted event. + /// + public const string TeamDeleted = "teamDeleted"; + + /// + /// Team renamed event. + /// + public const string TeamRenamed = "teamRenamed"; + + /// + /// Team unarchived event. + /// + public const string TeamUnarchived = "teamUnarchived"; + + /*TODO : review these events + /// + /// Team hard deleted event. + /// + public const string TeamHardDeleted = "teamHardDeleted"; + + /// + /// Team restored event. + /// + public const string TeamRestored = "teamRestored"; + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/EventActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/EventActivity.cs new file mode 100644 index 00000000..baaa5039 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/EventActivity.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents an event activity. +/// +public class EventActivity : TeamsActivity +{ + /// + /// Creates an EventActivity from a CoreActivity. + /// + public static new EventActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new EventActivity(activity); + } + + /// + /// Gets or sets the name of the event. See for common values. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public EventActivity() : base(TeamsActivityType.Event) + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + public EventActivity(string name) : base(TeamsActivityType.Event) + { + Name = name; + } + + /// + /// Initializes a new instance of the class from a CoreActivity. + /// + protected EventActivity(CoreActivity activity) : base(activity) + { + if (activity.Properties.TryGetValue("name", out object? name)) + { + Name = name?.ToString(); + activity.Properties.Remove("name"); + } + } +} + +/// +/// Represents an event activity with a strongly-typed value. +/// +/// The type of the value payload. +public class EventActivity : EventActivity +{ + /// + /// Gets or sets the strongly-typed value associated with the event activity. + /// Shadows the base class Value property, deserializing from the underlying JsonNode on access. + /// + public new TValue? Value + { + get => base.Value != null ? JsonSerializer.Deserialize(base.Value.ToJsonString()) : default; + set => base.Value = value != null ? JsonSerializer.SerializeToNode(value) : null; + } + + /// + /// Initializes a new instance of the class. + /// + public EventActivity() : base() + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + public EventActivity(string name) : base(name) + { + } + + /// + /// Initializes a new instance of the class from an EventActivity. + /// + public EventActivity(EventActivity activity) : base(activity) + { + } +} + +/// +/// String constants for event activity names. +/// +public static class EventNames +{ + /// Meeting start event name. + public const string MeetingStart = "application/vnd.microsoft.meetingStart"; + + /// Meeting end event name. + public const string MeetingEnd = "application/vnd.microsoft.meetingEnd"; + + /// Meeting participant join event name. + public const string MeetingParticipantJoin = "application/vnd.microsoft.meetingParticipantJoin"; + + /// Meeting participant leave event name. + public const string MeetingParticipantLeave = "application/vnd.microsoft.meetingParticipantLeave"; + + //TODO : review read receipts + /* + /// Read receipt event name. Fired when a user reads a message in a 1:1 chat with the bot. + public const string ReadReceipt = "application/vnd.microsoft.readReceipt"; + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InstallUpdateActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InstallUpdateActivity.cs new file mode 100644 index 00000000..35b51be9 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InstallUpdateActivity.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents an installation update activity. +/// +public class InstallUpdateActivity : TeamsActivity +{ + /// + /// Convenience method to create an InstallUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// An InstallUpdateActivity instance. + public static new InstallUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new InstallUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public InstallUpdateActivity() : base(TeamsActivityType.InstallationUpdate) + { + } + + /// + /// Internal constructor to create InstallUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected InstallUpdateActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Properties.TryGetValue("action", out object? action)) + { + Action = action?.ToString(); + activity.Properties.Remove("action"); + } + } + + /// + /// Gets or sets the action for the installation update. See for known values. + /// + [JsonPropertyName("action")] + public string? Action { get; set; } +} + +/// +/// String constants for installation update actions. +/// +public static class InstallUpdateActions +{ + /// + /// Add action constant. + /// + public const string Add = "add"; + + /// + /// Remove action constant. + /// + public const string Remove = "remove"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InvokeActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InvokeActivity.cs new file mode 100644 index 00000000..da20337b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/InvokeActivity.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents an invoke activity. +/// +public class InvokeActivity : TeamsActivity +{ + /// + /// Creates an InvokeActivity from a CoreActivity. + /// + /// + /// + public static new InvokeActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new InvokeActivity(activity); + } + + /// + /// Gets or sets the name of the operation. See for common values. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public InvokeActivity() : base(TeamsActivityType.Invoke) + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The invoke operation name. + + public InvokeActivity(string name) : base(TeamsActivityType.Invoke) + { + Name = name; + } + + /// + /// Initializes a new instance of the InvokeActivity class with the specified core activity. + /// + /// The core activity to be invoked. Cannot be null. + protected InvokeActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Properties.TryGetValue("name", out object? name)) + { + Name = name?.ToString(); + activity.Properties.Remove("name"); + } + } +} + +/// +/// Represents an invoke activity with a strongly-typed value. +/// +/// +/// The strongly-typed Value property provides compile-time type safety while maintaining a single storage location +/// through the base class. Both the typed and untyped Value properties access the same underlying JsonNode value. +/// +/// The type of the value payload. +public class InvokeActivity : InvokeActivity +{ + /// + /// Gets or sets the strongly-typed value associated with the invoke activity. + /// This property shadows the base class Value property but uses the same underlying storage, + /// ensuring no synchronization issues between typed and untyped access. + /// + public new TValue? Value + { + get => base.Value != null ? JsonSerializer.Deserialize(base.Value.ToJsonString()) : default; + set => base.Value = value != null ? JsonSerializer.SerializeToNode(value) : null; + } + + /// + /// Initializes a new instance of the class. + /// + public InvokeActivity() : base() + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The invoke operation name. + public InvokeActivity(string name) : base(name) + { + } + + /// + /// Initializes a new instance of the class from an InvokeActivity. + /// + /// The invoke activity. + public InvokeActivity(InvokeActivity activity) : base(activity) + { + } +} + +/// +/// String constants for invoke activity names. +/// +public static class InvokeNames +{ + /// + /// File consent invoke name. + /// + public const string FileConsent = "fileConsent/invoke"; + + /// + /// Adaptive card action invoke name. + /// + public const string AdaptiveCardAction = "adaptiveCard/action"; + + /// + /// Tab fetch invoke name. + /// + public const string TabFetch = "tab/fetch"; + + /// + /// Tab submit invoke name. + /// + public const string TabSubmit = "tab/submit"; + + /// + /// Task fetch invoke name. + /// + public const string TaskFetch = "task/fetch"; + + /// + /// Task submit invoke name. + /// + public const string TaskSubmit = "task/submit"; + + /// + /// Sign-in token exchange invoke name. + /// + public const string SignInTokenExchange = "signin/tokenExchange"; + + /// + /// Sign-in verify state invoke name. + /// + public const string SignInVerifyState = "signin/verifyState"; + + /// + /// Message extension anonymous query link invoke name. + /// + public const string MessageExtensionAnonQueryLink = "composeExtension/anonymousQueryLink"; + + /// + /// Message extension fetch task invoke name. + /// + public const string MessageExtensionFetchTask = "composeExtension/fetchTask"; + + /// + /// Message extension query invoke name. + /// + public const string MessageExtensionQuery = "composeExtension/query"; + + /// + /// Message extension query link invoke name. + /// + public const string MessageExtensionQueryLink = "composeExtension/queryLink"; + + /// + /// Message extension query setting URL invoke name. + /// + public const string MessageExtensionQuerySettingUrl = "composeExtension/querySettingUrl"; + + /// + /// Message extension select item invoke name. + /// + public const string MessageExtensionSelectItem = "composeExtension/selectItem"; + + /// + /// Message extension submit action invoke name. + /// + public const string MessageExtensionSubmitAction = "composeExtension/submitAction"; + + //TODO : review + /* + /// + /// Execute action invoke name. + /// + public const string ExecuteAction = "actionableMessage/executeAction"; + + /// + /// Handoff invoke name. + /// + public const string Handoff = "handoff/action"; + + /// + /// Search invoke name. + /// + public const string Search = "search"; + /// + /// Config fetch invoke name. + /// + public const string ConfigFetch = "config/fetch"; + + /// + /// Config submit invoke name. + /// + public const string ConfigSubmit = "config/submit"; + + /// + /// Message submit action invoke name. + /// + public const string MessageSubmitAction = "message/submitAction"; + + /// + /// Message extension card button clicked invoke name. + /// + public const string MessageExtensionCardButtonClicked = "composeExtension/onCardButtonClicked"; + + /// + /// Message extension setting invoke name. + /// + public const string MessageExtensionSetting = "composeExtension/setting"; + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MeetingValues.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MeetingValues.cs new file mode 100644 index 00000000..b5a5846a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MeetingValues.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Value payload for a meeting start event. +/// +public class MeetingStartValue +{ + /// The meeting's Id, encoded as a BASE64 string. + [JsonPropertyName("Id")] + public required string Id { get; set; } + + /// The meeting's type. + [JsonPropertyName("MeetingType")] + public string? MeetingType { get; set; } = string.Empty; + + /// The URL used to join the meeting. + [JsonPropertyName("JoinUrl")] + public Uri? JoinUrl { get; set; } + + /// The title of the meeting. + [JsonPropertyName("Title")] + public string? Title { get; set; } = string.Empty; + + /// Timestamp for meeting start, in UTC. + [JsonPropertyName("StartTime")] + public string? StartTime { get; set; } +} + +/// +/// Value payload for a meeting end event. +/// +public class MeetingEndValue +{ + /// The meeting's Id, encoded as a BASE64 string. + [JsonPropertyName("Id")] + public required string Id { get; set; } + + /// The meeting's type. + [JsonPropertyName("MeetingType")] + public string? MeetingType { get; set; } + + /// The URL used to join the meeting. + [JsonPropertyName("JoinUrl")] + public Uri? JoinUrl { get; set; } + + /// The title of the meeting. + [JsonPropertyName("Title")] + public string? Title { get; set; } + + /// Timestamp for meeting end, in UTC. + [JsonPropertyName("EndTime")] + public string? EndTime { get; set; } +} + +/// +/// Value payload for a meeting participant join event. +/// +public class MeetingParticipantJoinValue +{ + /// The list of participants who joined. + [JsonPropertyName("members")] + public IList Members { get; set; } = []; +} + +/// +/// Value payload for a meeting participant leave event. +/// +public class MeetingParticipantLeaveValue +{ + /// The list of participants who left. + [JsonPropertyName("members")] + public IList Members { get; set; } = []; +} + +/// +/// Represents a member in a meeting participant event. +/// +public class MeetingParticipantMember +{ + /// The participant's account. + [JsonPropertyName("user")] + public TeamsConversationAccount User { get; set; } = new(); + + /// The participant's meeting info. + [JsonPropertyName("meeting")] + public MeetingParticipantInfo Meeting { get; set; } = new(); +} + +/// +/// Represents a participant's meeting info. +/// +public class MeetingParticipantInfo +{ + /// Whether the user is currently in the meeting. + [JsonPropertyName("inMeeting")] + public bool InMeeting { get; set; } + + /// The participant's role in the meeting. + [JsonPropertyName("role")] + public string? Role { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs new file mode 100644 index 00000000..e4ba52fd --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a message activity. +/// +public class MessageActivity : TeamsActivity +{ + + /// + /// Convenience method to create a MessageActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageActivity instance. + public static new MessageActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageActivity() : base(TeamsActivityType.Message) + { + } + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The text content of the message. + public MessageActivity(string text) : base(TeamsActivityType.Message) + { + Text = text; + } + + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The list of attachments for the message. + public MessageActivity(IList attachments) : base(TeamsActivityType.Message) + { + Attachments = attachments; + } + + /// + /// Internal constructor to create MessageActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageActivity(CoreActivity activity) : base(activity) + { + if (activity.Properties.TryGetValue("text", out object? text)) + { + Text = text?.ToString(); + activity.Properties.Remove("text"); + } + if (activity.Properties.TryGetValue("textFormat", out object? textFormat)) + { + TextFormat = textFormat?.ToString(); + activity.Properties.Remove("textFormat"); + } + if (activity.Properties.TryGetValue("attachmentLayout", out object? attachmentLayout)) + { + AttachmentLayout = attachmentLayout?.ToString(); + activity.Properties.Remove("attachmentLayout"); + } + /* + if (activity.Properties.TryGetValue("speak", out var speak)) + { + Speak = speak?.ToString(); + activity.Properties.Remove("speak"); + } + if (activity.Properties.TryGetValue("inputHint", out var inputHint)) + { + InputHint = inputHint?.ToString(); + activity.Properties.Remove("inputHint"); + } + if (activity.Properties.TryGetValue("summary", out var summary)) + { + Summary = summary?.ToString(); + activity.Properties.Remove("summary"); + } + if (activity.Properties.TryGetValue("importance", out var importance)) + { + Importance = importance?.ToString(); + activity.Properties.Remove("importance"); + } + if (activity.Properties.TryGetValue("deliveryMode", out var deliveryMode)) + { + DeliveryMode = deliveryMode?.ToString(); + activity.Properties.Remove("deliveryMode"); + } + if (activity.Properties.TryGetValue("expiration", out var expiration)) + { + Expiration = expiration?.ToString(); + activity.Properties.Remove("expiration"); + } + */ + } + + /// + /// Gets or sets the text content of the message. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + /// + /// Gets or sets the text format. See for common values. + /// + [JsonPropertyName("textFormat")] + public string? TextFormat { get; set; } + + /// + /// Gets or sets the attachment layout. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + //TODO : Review properties + /* + /// + /// Gets or sets the SSML speak content of the message. + /// + [JsonPropertyName("speak")] + public string? Speak { get; set; } + + /// + /// Gets or sets the input hint. See for common values. + /// + [JsonPropertyName("inputHint")] + public string? InputHint { get; set; } + + /// + /// Gets or sets the summary of the message. + /// + [JsonPropertyName("summary")] + public string? Summary { get; set; } + + /// + /// Gets or sets the importance. See for common values. + /// + [JsonPropertyName("importance")] + public string? Importance { get; set; } + + /// + /// Gets or sets the delivery mode. See for common values. + /// + [JsonPropertyName("deliveryMode")] + public string? DeliveryMode { get; set; } + + /// + /// Gets or sets the expiration time of the message. + /// + [JsonPropertyName("expiration")] + public string? Expiration { get; set; } + + [JsonPropertyName("suggestedActions")] + public SuggestedActions? SuggestedActions { get; set; } + */ + +} + +/// +/// String constants for text formats. +/// +public static class TextFormats +{ + /// + /// Plain text format. + /// + public const string Plain = "plain"; + + /// + /// Markdown text format. + /// + public const string Markdown = "markdown"; + + /// + /// XML text format. + /// + public const string Xml = "xml"; +} + + +/* +/// +/// String constants for input hints. +/// +public static class InputHints +{ + /// + /// Accepting input hint. + /// + public const string AcceptingInput = "acceptingInput"; + + /// + /// Ignoring input hint. + /// + public const string IgnoringInput = "ignoringInput"; + + /// + /// Expecting input hint. + /// + public const string ExpectingInput = "expectingInput"; +} + +/// +/// String constants for importance levels. +/// +public static class ImportanceLevels +{ + /// + /// Low importance. + /// + public const string Low = "low"; + + /// + /// Normal importance. + /// + public const string Normal = "normal"; + + /// + /// High importance. + /// + public const string High = "high"; + + /// + /// Urgent importance. + /// + public const string Urgent = "urgent"; +} + +/// +/// String constants for delivery modes. +/// +public static class DeliveryModes +{ + /// + /// Normal delivery mode. + /// + public const string Normal = "normal"; + + /// + /// Notification delivery mode. + /// + public const string Notification = "notification"; + + /// + /// Ephemeral delivery mode. + /// + public const string Ephemeral = "ephemeral"; + + /// + /// Expected replies delivery mode. + /// + public const string ExpectedReplies = "expectReplies"; +} + + +public class SuggestedActions +{ + /// + /// Ids of the recipients that the actions should be shown to. These Ids are relative to the + /// channelId and a subset of all recipients of the activity + /// + [JsonPropertyName("to")] + public IList To { get; set; } = []; + + /// + /// Actions that can be shown to the user + /// + [JsonPropertyName("actions")] + public IList Actions { get; set; } = []; +} +*/ diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageDeleteActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageDeleteActivity.cs new file mode 100644 index 00000000..df392c17 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageDeleteActivity.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a message delete activity. +/// +public class MessageDeleteActivity : TeamsActivity +{ + /// + /// Convenience method to create a MessageDeleteActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageDeleteActivity instance. + public static new MessageDeleteActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageDeleteActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageDeleteActivity() : base(TeamsActivityType.MessageDelete) + { + } + + /// + /// Internal constructor to create MessageDeleteActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageDeleteActivity(CoreActivity activity) : base(activity) + { + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageReactionActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageReactionActivity.cs new file mode 100644 index 00000000..63f1289e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageReactionActivity.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a message reaction activity. +/// +public class MessageReactionActivity : TeamsActivity +{ + /// + /// Convenience method to create a MessageReactionActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageReactionActivity instance. + public static new MessageReactionActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageReactionActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageReactionActivity() : base(TeamsActivityType.MessageReaction) + { + } + + /// + /// Internal constructor to create MessageReactionActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageReactionActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Properties.TryGetValue("reactionsAdded", out object? reactionsAdded) && reactionsAdded != null) + { + if (reactionsAdded is JsonElement je) + { + ReactionsAdded = JsonSerializer.Deserialize>(je.GetRawText()); + } + else + { + ReactionsAdded = reactionsAdded as IList; + } + activity.Properties.Remove("reactionsAdded"); + } + if (activity.Properties.TryGetValue("reactionsRemoved", out object? reactionsRemoved) && reactionsRemoved != null) + { + if (reactionsRemoved is JsonElement je) + { + ReactionsRemoved = JsonSerializer.Deserialize>(je.GetRawText()); + } + else + { + ReactionsRemoved = reactionsRemoved as IList; + } + activity.Properties.Remove("reactionsRemoved"); + } + if (activity.Properties.TryGetValue("replyToId", out object? replyToId) && replyToId != null) + { + if (replyToId is JsonElement jeReplyToId && jeReplyToId.ValueKind == JsonValueKind.String) + { + ReplyToId = jeReplyToId.GetString(); + } + else + { + ReplyToId = replyToId.ToString(); + } + activity.Properties.Remove("replyToId"); + } + } + + /// + /// Gets or sets the reactions added to the message. + /// + [JsonPropertyName("reactionsAdded")] + public IList? ReactionsAdded { get; set; } + + /// + /// Gets or sets the reactions removed from the message. + /// + [JsonPropertyName("reactionsRemoved")] + public IList? ReactionsRemoved { get; set; } +} + +/// +/// Represents a reaction to a message. +/// +public class MessageReaction +{ + /// + /// Gets or sets the type of reaction. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// String constants for reaction types. +/// +public static class ReactionTypes +{ + /// + /// Like reaction. + /// + public const string Like = "like"; + + /// + /// Heart reaction. + /// + public const string Heart = "heart"; + + /// + /// Laugh reaction. + /// + public const string Laugh = "laugh"; + + /// + /// Surprise reaction. + /// + public const string Surprise = "surprise"; + + /// + /// Sad reaction. + /// + public const string Sad = "sad"; + + /// + /// Angry reaction. + /// + public const string Angry = "angry"; + + /// + /// Plus one reaction. + /// + public const string PlusOne = "plusOne"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageUpdateActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageUpdateActivity.cs new file mode 100644 index 00000000..7a2ead30 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageUpdateActivity.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a message update activity. +/// +public class MessageUpdateActivity : MessageActivity +{ + /// + /// Convenience method to create a MessageUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageUpdateActivity instance. + public static new MessageUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageUpdateActivity() : base() + { + Type = TeamsActivityType.MessageUpdate; + } + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The text content of the message. + public MessageUpdateActivity(string text) : base(text) + { + Type = TeamsActivityType.MessageUpdate; + } + + /// + /// Internal constructor to create MessageUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageUpdateActivity(CoreActivity activity) : base(activity) + { + Type = TeamsActivityType.MessageUpdate; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/CitationEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/CitationEntity.cs new file mode 100644 index 00000000..353f44b1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/CitationEntity.cs @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Extension methods for Activity to handle citations and AI-generated content. +/// +public static class ActivityCitationExtensions +{ + /// + /// Adds a citation to the activity. Creates or updates the root message entity + /// with the specified citation claim. + /// + /// The activity to add the citation to. Cannot be null. + /// The position of the citation in the message text. + /// The citation appearance information. + /// The created CitationEntity that was added to the activity. + public static CitationEntity AddCitation(this TeamsActivity activity, int position, CitationAppearance appearance) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(appearance); + + activity.Entities ??= []; + var messageEntity = GetOrCreateRootMessageEntity(activity); + var citationEntity = new CitationEntity(messageEntity); + citationEntity.Citation ??= []; + citationEntity.Citation.Add(new CitationClaim() + { + Position = position, + Appearance = appearance.ToDocument() + }); + + activity.Entities.Remove(messageEntity); + activity.Entities.Add(citationEntity); + activity.Rebase(); + return citationEntity; + } + + /// + /// Adds the AI-generated content label to the activity's root message entity. + /// This method is idempotent — calling it multiple times has the same effect as calling it once. + /// + /// The activity to mark as AI-generated. Cannot be null. + /// The OMessageEntity with the AI-generated label applied. + public static OMessageEntity AddAIGenerated(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + var messageEntity = GetOrCreateRootMessageEntity(activity); + messageEntity.AdditionalType ??= []; + + if (!messageEntity.AdditionalType.Contains("AIGeneratedContent")) + { + messageEntity.AdditionalType.Add("AIGeneratedContent"); + } + + activity.Rebase(); + return messageEntity; + } + + /// + /// Enables the feedback loop (thumbs up/down) on the activity's channel data. + /// + /// The activity to enable feedback on. Cannot be null. + /// Whether to enable feedback. Defaults to true. + /// The activity for chaining. + public static TeamsActivity AddFeedback(this TeamsActivity activity, bool value = true) + { + ArgumentNullException.ThrowIfNull(activity); + + activity.ChannelData ??= new TeamsChannelData(); + activity.ChannelData.FeedbackLoopEnabled = value; + return activity; + } + + // Gets or creates the single root-level OMessageEntity on the activity. + private static OMessageEntity GetOrCreateRootMessageEntity(TeamsActivity activity) + { + activity.Entities ??= []; + + var messageEntity = activity.Entities.FirstOrDefault( + e => e.Type == "https://schema.org/Message" && e.OType == "Message" + ) as OMessageEntity; + + if (messageEntity is null) + { + messageEntity = new OMessageEntity(); + activity.Entities.Add(messageEntity); + } + + return messageEntity; + } +} + +/// +/// Citation entity representing a message with citation claims. +/// +public class CitationEntity : OMessageEntity +{ + /// + /// Creates a new instance of . + /// + public CitationEntity() : base() + { + } + + /// + /// Creates a new instance of by copying data from an existing message entity. + /// + /// The message entity to copy from. Cannot be null. + public CitationEntity(OMessageEntity entity) : base() + { + ArgumentNullException.ThrowIfNull(entity); + OType = entity.OType; + OContext = entity.OContext; + Type = entity.Type; + AdditionalType = entity.AdditionalType != null + ? new List(entity.AdditionalType) + : null; + if (entity is CitationEntity citationEntity) + { + Citation = citationEntity.Citation != null + ? new List(citationEntity.Citation) + : null; + } + } + + /// + /// Gets or sets the list of citation claims. + /// + [JsonPropertyName("citation")] + public IList? Citation + { + get => base.Properties.TryGetValue("citation", out object? value) ? value as IList : null; + set => base.Properties["citation"] = value; + } +} + +/// +/// Represents a citation claim with a position and appearance document. +/// +public class CitationClaim +{ + /// + /// Gets or sets the schema.org type. Always "Claim". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "Claim"; + + /// + /// Gets or sets the position of the citation in the message text. + /// + [JsonPropertyName("position")] + public required int Position { get; set; } + + /// + /// Gets or sets the appearance document describing the cited source. + /// + [JsonPropertyName("appearance")] + public required CitationAppearanceDocument Appearance { get; set; } +} + +/// +/// Represents the appearance of a cited document. +/// +public class CitationAppearanceDocument +{ + /// + /// Gets or sets the schema.org type. Always "DigitalDocument". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "DigitalDocument"; + + /// + /// Gets or sets the name of the document (max length 80). + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets a stringified adaptive card with additional information about the citation. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the URL of the document. + /// + [JsonPropertyName("url")] + public Uri? Url { get; set; } + + /// + /// Gets or sets the extract of the referenced content (max length 160). + /// + [JsonPropertyName("abstract")] + public required string Abstract { get; set; } + + /// + /// Gets or sets the encoding format of the text. See for known values. + /// + [JsonPropertyName("encodingFormat")] + public string? EncodingFormat { get; set; } + + /// + /// Gets or sets the citation icon information. + /// + [JsonPropertyName("image")] + public CitationImageObject? Image { get; set; } + + /// + /// Gets or sets the keywords (max length 3, max keyword length 28). + /// + [JsonPropertyName("keywords")] + public IList? Keywords { get; set; } + + /// + /// Gets or sets the sensitivity usage information for the citation. + /// + [JsonPropertyName("usageInfo")] + public SensitiveUsageEntity? UsageInfo { get; set; } +} + +/// +/// Represents an image object used for citation icons. +/// +public class CitationImageObject +{ + /// + /// Gets or sets the schema.org type. Always "ImageObject". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "ImageObject"; + + /// + /// Gets or sets the icon name. See for known values. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// +/// Known citation icon names. +/// +public static class CitationIcon +{ + /// Microsoft Word icon. + public const string MicrosoftWord = "Microsoft Word"; + + /// Microsoft Excel icon. + public const string MicrosoftExcel = "Microsoft Excel"; + + /// Microsoft PowerPoint icon. + public const string MicrosoftPowerPoint = "Microsoft PowerPoint"; + + /// Microsoft OneNote icon. + public const string MicrosoftOneNote = "Microsoft OneNote"; + + /// Microsoft SharePoint icon. + public const string MicrosoftSharePoint = "Microsoft SharePoint"; + + /// Microsoft Visio icon. + public const string MicrosoftVisio = "Microsoft Visio"; + + /// Microsoft Loop icon. + public const string MicrosoftLoop = "Microsoft Loop"; + + /// Microsoft Whiteboard icon. + public const string MicrosoftWhiteboard = "Microsoft Whiteboard"; + + /// Adobe Illustrator icon. + public const string AdobeIllustrator = "Adobe Illustrator"; + + /// Adobe Photoshop icon. + public const string AdobePhotoshop = "Adobe Photoshop"; + + /// Adobe InDesign icon. + public const string AdobeInDesign = "Adobe InDesign"; + + /// Adobe Flash icon. + public const string AdobeFlash = "Adobe Flash"; + + /// Sketch icon. + public const string Sketch = "Sketch"; + + /// Source code icon. + public const string SourceCode = "Source Code"; + + /// Image icon. + public const string Image = "Image"; + + /// GIF icon. + public const string Gif = "GIF"; + + /// Video icon. + public const string Video = "Video"; + + /// Sound icon. + public const string Sound = "Sound"; + + /// ZIP icon. + public const string Zip = "ZIP"; + + /// Text icon. + public const string Text = "Text"; + + /// PDF icon. + public const string Pdf = "PDF"; +} + +/// +/// Known encoding format MIME types for citation documents. +/// +public static class EncodingFormats +{ + /// Adaptive card encoding format. + public const string AdaptiveCard = "application/vnd.microsoft.card.adaptive"; +} + +/// +/// Helper class for building citation appearance documents. +/// +public class CitationAppearance +{ + /// + /// Gets or sets the name of the document (max length 80). + /// + public required string Name { get; set; } + + /// + /// Gets or sets a stringified adaptive card with additional information. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the URL of the document. + /// + public Uri? Url { get; set; } + + /// + /// Gets or sets the extract of the referenced content (max length 160). + /// + public required string Abstract { get; set; } + + /// + /// Gets or sets the encoding format of the text. See for known values. + /// + public string? EncodingFormat { get; set; } + + /// + /// Gets or sets the citation icon name. See for known values. + /// + public string? Icon { get; set; } + + /// + /// Gets or sets the keywords (max length 3, max keyword length 28). + /// + public IList? Keywords { get; set; } + + /// + /// Gets or sets the sensitivity usage information. + /// + public SensitiveUsageEntity? UsageInfo { get; set; } + + /// + /// Converts this appearance to a . + /// + /// The appearance document. + public CitationAppearanceDocument ToDocument() + { + return new() + { + Name = Name, + Text = Text, + Url = Url, + Abstract = Abstract, + EncodingFormat = EncodingFormat, + Image = Icon is null ? null : new CitationImageObject() { Name = Icon }, + Keywords = Keywords, + UsageInfo = UsageInfo + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs new file mode 100644 index 00000000..468b39bb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ClientInfoEntity.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + + +/// +/// Extension methods for Activity to handle client info. +/// +public static class ActivityClientInfoExtensions +{ + /// + /// Adds client information to the activity's entity collection. + /// + /// The activity to add client information to. Cannot be null. + /// The platform identifier (e.g., "web", "desktop", "mobile"). + /// The country code (e.g., "US", "GB"). + /// The time zone identifier (e.g., "America/New_York"). + /// The locale identifier (e.g., "en-US", "fr-FR"). + /// The created ClientInfoEntity that was added to the activity. + public static ClientInfoEntity AddClientInfo(this TeamsActivity activity, string platform, string country, string timeZone, string locale) + { + ArgumentNullException.ThrowIfNull(activity); + + ClientInfoEntity clientInfo = new(platform, country, timeZone, locale); + activity.Entities ??= []; + activity.Entities.Add(clientInfo); + activity.Rebase(); + return clientInfo; + } + + /// + /// Retrieves the client information entity from the activity's entity collection. + /// + /// The activity to extract client information from. Cannot be null. + /// The ClientInfoEntity if found in the activity's entities; otherwise, null. + public static ClientInfoEntity? GetClientInfo(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Entities == null) + { + return null; + } + ClientInfoEntity? clientInfo = activity.Entities.FirstOrDefault(e => e is ClientInfoEntity) as ClientInfoEntity; + + return clientInfo; + } +} + +/// +/// Client info entity. +/// +public class ClientInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public ClientInfoEntity() : base("clientInfo") + { + } + + + /// + /// Initializes a new instance of the class with specified client information. + /// + /// The platform identifier (e.g., "web", "desktop", "mobile"). + /// The country code (e.g., "US", "GB"). + /// The time zone identifier (e.g., "America/New_York"). + /// The locale identifier (e.g., "en-US", "fr-FR"). + public ClientInfoEntity(string platform, string country, string timezone, string locale) : base("clientInfo") + { + Locale = locale; + Country = country; + Platform = platform; + Timezone = timezone; + } + + /// + /// Gets or sets the locale information. + /// + [JsonPropertyName("locale")] + public string? Locale + { + get => base.Properties.TryGetValue("locale", out object? value) ? value?.ToString() : null; + set => base.Properties["locale"] = value; + } + + /// + /// Gets or sets the country information. + /// + [JsonPropertyName("country")] + public string? Country + { + get => base.Properties.TryGetValue("country", out object? value) ? value?.ToString() : null; + set => base.Properties["country"] = value; + } + + /// + /// Gets or sets the platform information. + /// + [JsonPropertyName("platform")] + public string? Platform + { + get => base.Properties.TryGetValue("platform", out object? value) ? value?.ToString() : null; + set => base.Properties["platform"] = value; + } + + /// + /// Gets or sets the timezone information. + /// + [JsonPropertyName("timezone")] + public string? Timezone + { + get => base.Properties.TryGetValue("timezone", out object? value) ? value?.ToString() : null; + set => base.Properties["timezone"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs new file mode 100644 index 00000000..b292c28a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/Entity.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + + +/// +/// List of Entity objects. +/// +[JsonConverter(typeof(EntityListJsonConverter))] +public class EntityList : List +{ + /// + /// Converts the Entities collection to a JsonArray. + /// + /// + public JsonArray? ToJsonArray() + { + JsonArray jsonArray = []; + foreach (Entity entity in this) + { + JsonObject jsonObject = new() + { + ["type"] = entity.Type + }; + + foreach (KeyValuePair property in entity.Properties) + { + jsonObject[property.Key] = property.Value as JsonNode ?? JsonValue.Create(property.Value); + } + jsonArray.Add(jsonObject); + } + return jsonArray; + } + + /// + /// Parses a JsonArray into an Entities collection. + /// + /// + /// + /// + public static EntityList? FromJsonArray(JsonArray? jsonArray, JsonSerializerOptions? options = null) + { + if (jsonArray == null) + { + return null; + } + EntityList entities = []; + foreach (JsonNode? item in jsonArray) + { + if (item is JsonObject jsonObject + && jsonObject.TryGetPropertyValue("type", out JsonNode? typeNode) + && typeNode is JsonValue typeValue + && typeValue.GetValue() is string typeString) + { + + // TODO: Should be able to support unknown types (PA uses BotMessageMetadata). + // TODO: Investigate if there is any way for Parent to avoid + // Knowing the children. + // Maybe a registry pattern, or Converters? + Entity? entity = typeString switch + { + "clientInfo" => item.Deserialize(options), + "mention" => item.Deserialize(options), + "message" or "https://schema.org/Message" => DeserializeMessageEntity(item, options), + "ProductInfo" => item.Deserialize(options), + "streaminfo" => item.Deserialize(options), + _ => null + }; + if (entity != null) + entities.Add(entity); + } + } + return entities; + } + + /// + /// Deserializes a message entity by checking the @type property to determine the specific type. + /// + /// The JSON node to deserialize. + /// The JSON serializer options. + /// The deserialized entity, or null if deserialization fails. + private static OMessageEntity? DeserializeMessageEntity(JsonNode item, JsonSerializerOptions? options) + { + if (item is JsonObject jsonObject + && jsonObject.TryGetPropertyValue("@type", out JsonNode? oTypeNode) + && oTypeNode is JsonValue oTypeValue + && oTypeValue.GetValue() is string oType) + { + return oType switch + { + "Message" => item.Deserialize(options), + "CreativeWork" => item.Deserialize(options), + _ => item.Deserialize(options) + }; + } + + return item.Deserialize(options); + } +} + +/// +/// Entity base class. +/// +/// +/// Initializes a new instance of the Entity class with the specified type. +/// +/// The type of the entity. Cannot be null. +public class Entity(string type) +{ + /// + /// Gets or sets the type identifier for the object represented by this instance. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = type; + + /// + /// Gets or sets the OData type identifier for the object represented by this instance. + /// + [JsonPropertyName("@type")] public string? OType { get; set; } + + /// + /// Gets or sets the OData context for the object represented by this instance. + /// + [JsonPropertyName("@context")] public string? OContext { get; set; } + /// + /// Extended properties dictionary. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + +} + +/// +/// JSON converter for EntityList. +/// +public class EntityListJsonConverter : JsonConverter +{ + /// + /// Reads and converts the JSON to EntityList. + /// + public override EntityList? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + JsonArray? jsonArray = JsonSerializer.Deserialize(ref reader, options); + return EntityList.FromJsonArray(jsonArray, options); + } + + /// + /// Writes the EntityList as JSON. + /// + public override void Write(Utf8JsonWriter writer, EntityList value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(value); + JsonArray? jsonArray = value.ToJsonArray(); + JsonSerializer.Serialize(writer, jsonArray, options); + } +} + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs new file mode 100644 index 00000000..6c867b01 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/MentionEntity.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Extension methods for Activity to handle mentions. +/// +public static class ActivityMentionExtensions +{ + /// + /// Gets the MentionEntity from the activity's entities. + /// + /// The activity to extract the mention from. + /// The MentionEntity if found; otherwise, null. + public static IEnumerable GetMentions(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Entities == null) + { + return []; + } + return activity.Entities.Where(e => e is MentionEntity).Cast(); + } + + /// + /// Adds a mention (@ mention) of a user or bot to the activity. + /// + /// The activity to add the mention to. Cannot be null. + /// The conversation account being mentioned. Cannot be null. + /// Optional custom text for the mention. If null, uses the account name. + /// If true, prepends the mention text to the activity's existing text content. Defaults to true. + /// The created MentionEntity that was added to the activity. + public static MentionEntity AddMention(this TeamsActivity activity, ConversationAccount account, string? text = null, bool addText = true) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(account); + string? mentionText = text ?? account.Name; + if (addText && activity is MessageActivity msg) + { + msg.Text = $"{mentionText} {msg.Text}"; + } + activity.Entities ??= []; + MentionEntity mentionEntity = new(account, $"{mentionText}"); + activity.Entities.Add(mentionEntity); + activity.Rebase(); + return mentionEntity; + } +} + +/// +/// Mention entity. +/// +public class MentionEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public MentionEntity() : base("mention") { } + + /// + /// Creates a new instance of with the specified mentioned account and text. + /// + /// The conversation account being mentioned. + /// The text representation of the mention, typically formatted as "<at>name</at>". + public MentionEntity(ConversationAccount mentioned, string? text) : base("mention") + { + Mentioned = mentioned; + Text = text; + } + + /// + /// Mentioned conversation account. + /// + [JsonPropertyName("mentioned")] + public ConversationAccount? Mentioned + { + get => base.Properties.TryGetValue("mentioned", out object? value) ? value as ConversationAccount : null; + set => base.Properties["mentioned"] = value; + } + + /// + /// Text of the mention. + /// + [JsonPropertyName("text")] + public string? Text + { + get => base.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + set => base.Properties["text"] = value; + } + + /// + /// Creates a new instance of the MentionEntity class from the specified JSON node. + /// + /// A JsonNode containing the data to deserialize. Must include a 'mentioned' property representing a + /// ConversationAccount. + /// A MentionEntity object populated with values from the provided JSON node. + /// Thrown if jsonNode is null or does not contain the required 'mentioned' property. + public static MentionEntity FromJsonElement(JsonNode? jsonNode) + { + MentionEntity res = new() + { + // TODO: Verify if throwing exceptions is okay here + Mentioned = jsonNode?["mentioned"] != null + ? JsonSerializer.Deserialize(jsonNode["mentioned"]!.ToJsonString())! + : throw new ArgumentNullException(nameof(jsonNode), "mentioned property is required"), + Text = jsonNode?["text"]?.GetValue() + }; + return res; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs new file mode 100644 index 00000000..b0dd31c7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/OMessageEntity.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// OMessage entity. +/// +public class OMessageEntity : Entity +{ + + /// + /// Creates a new instance of . + /// + public OMessageEntity() : base("https://schema.org/Message") + { + OType = "Message"; + OContext = "https://schema.org"; + } + /// + /// Gets or sets the additional type. + /// + [JsonPropertyName("additionalType")] + public IList? AdditionalType + { + get => base.Properties.TryGetValue("additionalType", out object? value) ? value as IList : null; + set => base.Properties["additionalType"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs new file mode 100644 index 00000000..f08725f1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/ProductInfoEntity.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + + + + +/// +/// Product info entity. +/// +public class ProductInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public ProductInfoEntity() : base("ProductInfo") { } + + /// + /// Gets or sets the product id. + /// + [JsonPropertyName("id")] + public string? Id + { + get => base.Properties.TryGetValue("id", out object? value) ? value?.ToString() : null; + set => base.Properties["id"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs new file mode 100644 index 00000000..20b0d61c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/SensitiveUsageEntity.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents an entity that describes the usage of sensitive content, including its name, description, and associated +/// pattern. +/// +public class SensitiveUsageEntity : OMessageEntity +{ + /// + /// Creates a new instance of . + /// + public SensitiveUsageEntity() : base() => OType = "CreativeWork"; + + /// + /// Gets or sets the name of the sensitive usage. + /// + [JsonPropertyName("name")] + public required string Name + { + get => base.Properties.TryGetValue("name", out object? value) ? value?.ToString() ?? string.Empty : string.Empty; + set => base.Properties["name"] = value; + } + + /// + /// Gets or sets the description of the sensitive usage. + /// + [JsonPropertyName("description")] + public string? Description + { + get => base.Properties.TryGetValue("description", out object? value) ? value?.ToString() : null; + set => base.Properties["description"] = value; + } + + /// + /// Gets or sets the pattern associated with the sensitive usage. + /// + [JsonPropertyName("pattern")] + public DefinedTerm? Pattern + { + get => base.Properties.TryGetValue("pattern", out object? value) ? value as DefinedTerm : null; + set => base.Properties["pattern"] = value; + } +} + +/// +/// Defined term. +/// +public class DefinedTerm +{ + /// + /// Type of the defined term. + /// + [JsonPropertyName("@type")] public string Type { get; set; } = "DefinedTerm"; + + /// + /// OData type of the defined term. + /// + [JsonPropertyName("inDefinedTermSet")] public required string InDefinedTermSet { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public required string Name { get; set; } + + /// + /// Gets or sets the code that identifies the academic term. + /// + [JsonPropertyName("termCode")] public required string TermCode { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs new file mode 100644 index 00000000..eb7fcc72 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Entities/StreamInfoEntity.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Stream info entity. +/// +public class StreamInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public StreamInfoEntity() : base("streaminfo") { } + + /// + /// Gets or sets the stream id. + /// + [JsonPropertyName("streamId")] + public string? StreamId + { + get => base.Properties.TryGetValue("streamId", out object? value) ? value?.ToString() : null; + set => base.Properties["streamId"] = value; + } + + /// + /// Gets or sets the stream type. See for possible values. + /// + [JsonPropertyName("streamType")] + public string? StreamType + { + get => base.Properties.TryGetValue("streamType", out object? value) ? value?.ToString() : null; + set => base.Properties["streamType"] = value; + } + + /// + /// Gets or sets the stream sequence. + /// + [JsonPropertyName("streamSequence")] + public int? StreamSequence + { + get => base.Properties.TryGetValue("streamSequence", out object? value) && value != null + ? (int.TryParse(value.ToString(), out int intVal) ? intVal : null) + : null; + set => base.Properties["streamSequence"] = value; + } +} + +/// +/// Represents the types of streams. +/// +public static class StreamType +{ + /// + /// Informative stream type. + /// + public const string Informative = "informative"; + /// + /// Streaming stream type. + /// + public const string Streaming = "streaming"; + /// + /// Represents the string literal "final". + /// + public const string Final = "final"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardActionValue.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardActionValue.cs new file mode 100644 index 00000000..b526fad3 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardActionValue.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Defines the structure that arrives in the Activity.Value for Invoke activity with +/// Name of 'adaptiveCard/action'. +/// +public class AdaptiveCardActionValue +{ + /// + /// The action of this adaptive card invoke action value. + /// + [JsonPropertyName("action")] + public AdaptiveCardAction? Action { get; set; } + + /// + /// The state for this adaptive card invoke action value. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// What triggered the action. + /// + [JsonPropertyName("trigger")] + public string? Trigger { get; set; } +} + +/// +/// Defines the structure that arrives in the Activity.Value.Action for Invoke +/// activity with Name of 'adaptiveCard/action'. +/// +public class AdaptiveCardAction +{ + /// + /// The Type of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The id of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The title of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The Verb of this adaptive card action invoke. + /// + [JsonPropertyName("verb")] + public string? Verb { get; set; } + + /// + /// The Data of this adaptive card action invoke. + /// + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardResponse.cs new file mode 100644 index 00000000..57ec7aa1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/AdaptiveCardResponse.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Adaptive card response types. +/// +public static class AdaptiveCardResponseType +{ + /// + /// Message type - displays a message to the user. + /// + public const string Message = "application/vnd.microsoft.activity.message"; + + /// + /// Card type - updates the card with new content. + /// + public const string Card = "application/vnd.microsoft.card.adaptive"; +} + +/// +/// +/// Response for adaptive card action activities. +/// +public class AdaptiveCardResponse +{ + /// + /// HTTP status code for the response. + /// + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 200; + + /// + /// Type of response. See for common values. + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } + + /// + /// Value for the response. Can be a string message or card content. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; set; } + + /// + /// Creates a new builder for AdaptiveCardResponse. + /// + public static AdaptiveCardResponseBuilder CreateBuilder() + { + return new AdaptiveCardResponseBuilder(); + } + + /// + /// Creates a InvokeResponse with a message response. + /// + /// The message to display to the user. + /// The HTTP status code (default: 200). + public static InvokeResponse CreateMessageResponse(string message, int statusCode = 200) + { + return new InvokeResponse(statusCode, new AdaptiveCardResponse + { + StatusCode = statusCode, + Type = AdaptiveCardResponseType.Message, + Value = message + }); + } + + /// + /// Creates a InvokeResponse with a card response. + /// + /// The card content to display. + /// The HTTP status code (default: 200). + public static InvokeResponse CreateCardResponse(object card, int statusCode = 200) + { + return new InvokeResponse(statusCode, new AdaptiveCardResponse + { + StatusCode = statusCode, + Type = AdaptiveCardResponseType.Card, + Value = card + }); + } +} + +/// +/// Builder for AdaptiveCardResponse. +/// +public class AdaptiveCardResponseBuilder +{ + private int _statusCode = 200; + private string? _type; + private object? _value; + + /// + /// + public AdaptiveCardResponseBuilder WithStatusCode(int statusCode) + { + _statusCode = statusCode; + return this; + } + + /// + /// Sets the type of the response. See for common values. + /// + public AdaptiveCardResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the value for the response. + /// + public AdaptiveCardResponseBuilder WithValue(object value) + { + _value = value; + return this; + } + + /// + /// Builds the AdaptiveCardResponse and wraps it in a InvokeResponse. + /// + public InvokeResponse Build() + { + return new InvokeResponse(_statusCode, new AdaptiveCardResponse + { + StatusCode = _statusCode, + Type = _type, + Value = _value + }); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/FileConsentValue.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/FileConsentValue.cs new file mode 100644 index 00000000..c7bc3a76 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/FileConsentValue.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents the value of the invoke activity sent when the user acts on a +/// file consent card. +/// +public class FileConsentValue +{ + /// + /// The type of file consent activity. Typically "fileUpload". + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The action the user took. Possible values: 'accept', 'decline'. + /// + [JsonPropertyName("action")] + public string? Action { get; set; } + + /// + /// The context associated with the action. + /// + [JsonPropertyName("context")] + public object? Context { get; set; } + + /// + /// If the user accepted the file, + /// contains information about the file to be uploaded. + /// + [JsonPropertyName("uploadInfo")] + public FileUploadInfo? UploadInfo { get; set; } +} + +/// +/// File upload info for accepted file consent. +/// +public class FileUploadInfo +{ + /// + /// Name of the file. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// URL to upload file content. + /// + [JsonPropertyName("uploadUrl")] + public Uri? UploadUrl { get; set; } + + /// + /// URL to file content after upload. + /// + [JsonPropertyName("contentUrl")] + public Uri? ContentUrl { get; set; } + + /// + /// Unique ID for the file. + /// + [JsonPropertyName("uniqueId")] + public string? UniqueId { get; set; } + + /// + /// Type of the file. + /// + [JsonPropertyName("fileType")] + public string? FileType { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/InvokeResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/InvokeResponse.cs new file mode 100644 index 00000000..f31f20c7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/InvokeResponse.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + + +/// +/// Represents the response returned from an invocation handler, typically used for Adaptive Card actions and task module operations. +/// +/// +/// This class encapsulates the HTTP-style response sent back to Teams when handling invoke activities. +/// Common status codes include 200 for success, 400 for bad request, and 500 for errors. +/// The Body property contains the response payload, which is serialized to JSON and returned to the client. +/// +/// The HTTP status code indicating the result of the invoke operation (e.g., 200 for success). +/// Optional response payload that will be serialized and sent to the client. +public class InvokeResponse(int status, object? body = null) +{ + /// + /// Status code of the response. + /// + [JsonPropertyName("status")] + public int Status { get; set; } = status; + + /// + /// Gets or sets the response body. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Body { get; set; } = body; +} + +/// +/// Represents a strongly-typed response returned from an invocation handler. +/// +/// +/// The strongly-typed Body property provides compile-time type safety while maintaining a single storage location +/// through the base class. Both the typed and untyped Body properties access the same underlying body. +/// +/// The type of the response body. +/// The HTTP status code indicating the result of the invoke operation (e.g., 200 for success). +/// Optional strongly-typed response payload that will be serialized and sent to the client. +public class InvokeResponse(int status, TBody? body = default) : InvokeResponse(status, body) where TBody : notnull +{ + /// + /// Gets or sets the strongly-typed response body. + /// This property shadows the base class Body property but uses the same underlying storage, + /// ensuring no synchronization issues between typed and untyped access. + /// + public new TBody? Body + { + get => (TBody?)base.Body; + set => base.Body = value; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionAction.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionAction.cs new file mode 100644 index 00000000..39b070bf --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionAction.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Message extension command context values. +/// +public static class MessageExtensionCommandContext +{ + /// + /// Command invoked from a message (message action). + /// + public const string Message = "message"; + + /// + /// Command invoked from the compose box. + /// + public const string Compose = "compose"; + + /// + /// Command invoked from the command box. + /// + public const string CommandBox = "commandbox"; +} + +/// +/// Bot message preview action values. +/// +public static class BotMessagePreviewAction +{ + /// + /// User clicked edit on the preview. + /// + public const string Edit = "edit"; + + /// + /// User clicked send on the preview. + /// + public const string Send = "send"; +} + +/// +/// Context information for message extension actions. +/// +public class MessageExtensionContext +{ + /// + /// The theme of the Teams client. Common values: "default", "dark", "contrast". + /// + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} + +/// +/// Message extension action payload for submit action and fetch task activities. +/// +public class MessageExtensionAction +{ + /// + /// Id of the command assigned by the bot. + /// + [JsonPropertyName("commandId")] + public required string CommandId { get; set; } + + /// + /// The context from which the command originates. + /// See for common values. + /// + [JsonPropertyName("commandContext")] + public required string CommandContext { get; set; } + + /// + /// Bot message preview action taken by user. + /// See for common values. + /// + [JsonPropertyName("botMessagePreviewAction")] + public string? BotMessagePreviewAction { get; set; } + + /// + /// The activity preview that was originally sent to Teams when showing the bot message preview. + /// This is sent back by Teams when the user clicks 'edit' or 'send' on the preview. + /// + // TODO : this needs to be activity type or something else - format is type, attachments[] + [JsonPropertyName("botActivityPreview")] + public IList? BotActivityPreview { get; set; } + + /// + /// Data included with the submit action. + /// + [JsonPropertyName("data")] + public object? Data { get; set; } + + /// + /// Message content sent as part of the command request when the command is invoked from a message. + /// + [JsonPropertyName("messagePayload")] + public MessagePayload? MessagePayload { get; set; } + + /// + /// Context information for the action. + /// + [JsonPropertyName("context")] + public MessageExtensionContext? Context { get; set; } +} + +/// +/// Represents the individual message within a chat or channel where a message +/// action is taken. +/// +public class MessagePayload +{ + /// + /// Unique id of the message. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Timestamp of when the message was created. + /// + [JsonPropertyName("createdDateTime")] + public string? CreatedDateTime { get; set; } + + /// + /// Indicates whether a message has been soft deleted. + /// + [JsonPropertyName("deleted")] + public bool? Deleted { get; set; } + + /// + /// Subject line of the message. + /// + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + /// + /// The importance of the message. + /// + /// + /// See for common values. + /// + [JsonPropertyName("importance")] + public string? Importance { get; set; } + + /// + /// Locale of the message set by the client. + /// + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + /// + /// Link back to the message. + /// + [JsonPropertyName("linkToMessage")] + public string? LinkToMessage { get; set; } + + /// + /// Sender of the message. + /// + [JsonPropertyName("from")] + public MessageFrom? From { get; set; } + + /// + /// Plaintext/HTML representation of the content of the message. + /// + [JsonPropertyName("body")] + public MessagePayloadBody? Body { get; set; } + + /// + /// How the attachment(s) are displayed in the message. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + /// + /// Attachments in the message - card, image, file, etc. + /// + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// + /// List of entities mentioned in the message. + /// + [JsonPropertyName("mentions")] + public IList? Mentions { get; set; } + + /// + /// Reactions for the message. + /// + [JsonPropertyName("reactions")] + public IList? Reactions { get; set; } +} + +/// +/// Sender of the message. +/// +public class MessageFrom +{ + /// + /// User information of the sender. + /// + [JsonPropertyName("user")] + public User? User { get; set; } +} + +/// +/// String constants for message importance levels. +/// +public static class MessagePayloadImportance +{ + /// + /// Normal importance. + /// + public const string Normal = "normal"; + + /// + /// High importance. + /// + public const string High = "high"; + + /// + /// Urgent importance. + /// + public const string Urgent = "urgent"; +} + +/// +/// Message body content. +/// +public class MessagePayloadBody +{ + /// + /// Type of content. Common values: "text", "html". + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +/// +/// Attachment in a message payload. +/// +public class MessagePayloadAttachment +{ + /// + /// Unique identifier for the attachment. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Type of attachment content. See for common values. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// The attachment content. + /// + [JsonPropertyName("content")] + public object? Content { get; set; } +} + +/// +/// Reaction to a message. +/// +public class MessagePayloadReaction +{ + /// + /// Type of reaction + /// See for common values. + /// + [JsonPropertyName("reactionType")] + public string? ReactionType { get; set; } + + /// + /// Timestamp when the reaction was created. + /// + [JsonPropertyName("createdDateTime")] + public string? CreatedDateTime { get; set; } + + /// + /// User who reacted. + /// + [JsonPropertyName("user")] + public User? User { get; set; } +} + +/// +/// Represents a user who created a reaction. +/// +public class User +{ + /// + /// Gets or sets the user identifier. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the user identity type. + /// + [JsonPropertyName("userIdentityType")] + public string? UserIdentityType { get; set; } + + /// + /// Gets or sets the display name of the user. + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } +} + +/// +/// String constants for user identity types. +/// +public static class UserIdentityTypes +{ + /// + /// Azure Active Directory user. + /// + public const string AadUser = "aadUser"; + + /// + /// On-premise Azure Active Directory user. + /// + public const string OnPremiseAadUser = "onPremiseAadUser"; + + /// + /// Anonymous guest user. + /// + public const string AnonymousGuest = "anonymousGuest"; + + /// + /// Federated user. + /// + public const string FederatedUser = "federatedUser"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionActionResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionActionResponse.cs new file mode 100644 index 00000000..cd3a50a7 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionActionResponse.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a response from a message extension action that can contain either a task module or compose extension response. +/// +public class MessageExtensionActionResponse +{ + /// + /// The task module result. + /// + [JsonPropertyName("task")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Response? Task { get; set; } + + /// + /// The compose extension result (for message extension results, auth, config, etc.). + /// + [JsonPropertyName("composeExtension")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ComposeExtension? ComposeExtension { get; set; } + + /// + /// Creates a new builder for MessageExtensionActionResponse. + /// + public static MessageExtensionActionResponseBuilder CreateBuilder() + { + return new MessageExtensionActionResponseBuilder(); + } +} + +/// +/// Builder for MessageExtensionActionResponse. +/// +public class MessageExtensionActionResponseBuilder +{ + private TaskModuleResponse? _taskResponse; + private MessageExtensionResponse? _extensionResponse; + + /// + /// Sets the task module response using a TaskModuleResponseBuilder. + /// + public MessageExtensionActionResponseBuilder WithTask(TaskModuleResponseBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + _taskResponse = builder.Validate(); + return this; + } + + /// + /// Sets the compose extension response using a MessageExtensionResponseBuilder. + /// + public MessageExtensionActionResponseBuilder WithComposeExtension(MessageExtensionResponseBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + _extensionResponse = builder.Validate(); + return this; + } + + /// + /// Validates and builds the MessageExtensionActionResponse. + /// + private MessageExtensionActionResponse Validate() + { + if (_taskResponse == null && _extensionResponse == null) + { + throw new InvalidOperationException("Either Task or ComposeExtension must be set. Use WithTask() or WithComposeExtension()."); + } + + if (_taskResponse != null && _extensionResponse != null) + { + throw new InvalidOperationException("Cannot set both Task and ComposeExtension. Use either WithTask() or WithComposeExtension(), not both."); + } + + return new MessageExtensionActionResponse + { + Task = _taskResponse?.Task, + ComposeExtension = _extensionResponse?.ComposeExtension + }; + } + + /// + /// Builds the MessageExtensionActionResponse and wraps it in a InvokeResponse. + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQuery.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQuery.cs new file mode 100644 index 00000000..7da6685c --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQuery.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Messaging extension query payload. +/// +public class MessageExtensionQuery +{ + /// + /// Id of the command assigned by the bot. + /// + [JsonPropertyName("commandId")] + public required string CommandId { get; set; } + + /// + /// Parameters for the query. + /// + [JsonPropertyName("parameters")] + public required IList Parameters { get; set; } + + /// + /// Query options for pagination. + /// + [JsonPropertyName("queryOptions")] + public QueryOptions? QueryOptions { get; set; } + + //TODO : check how to use this ? auth ? + /* + /// + /// State parameter passed back to the bot after authentication/configuration flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + */ +} + +/// +/// Query parameter. +/// +public class QueryParameter +{ + /// + /// Name of the parameter. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Value of the parameter. + /// + [JsonPropertyName("value")] + public required string Value { get; set; } +} + + +/// +/// Query options for pagination. +/// +public class QueryOptions +{ + /// + /// Number of entities to skip. + /// + [JsonPropertyName("skip")] + public int? Skip { get; set; } + + /// + /// Number of entities to fetch. + /// + [JsonPropertyName("count")] + public int? Count { get; set; } +} + diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQueryLink.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQueryLink.cs new file mode 100644 index 00000000..78e2871a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionQueryLink.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// App-based query link payload for link unfurling. +/// +public class MessageExtensionQueryLink +{ + /// + /// URL queried by user. + /// + [JsonPropertyName("url")] + public Uri? Url { get; set; } + + //TODO : review + /* + /// + /// State parameter for OAuth flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + */ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionResponse.cs new file mode 100644 index 00000000..2924d2fc --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/MessageExtensionResponse.cs @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Messaging extension response types. +/// +public static class MessageExtensionResponseType +{ + /// + /// Result type - displays a list of search results. + /// + public const string Result = "result"; + + /// + /// Message type - displays a plain text message. + /// + public const string Message = "message"; + + /// + /// Bot message preview type - shows a preview that can be edited before sending. + /// + public const string BotMessagePreview = "botMessagePreview"; + + /// + /// Config type - prompts the user to set up the message extension. + /// + public const string Config = "config"; + + //TODO : review + /* + /// + /// Auth type - prompts the user to authenticate. + /// + public const string Auth = "auth"; + */ +} + +/// +/// Messaging extension response wrapper. +/// +public class MessageExtensionResponse +{ + /// + /// The compose extension result (for message extension results, auth, config, etc.). + /// + [JsonPropertyName("composeExtension")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ComposeExtension? ComposeExtension { get; set; } + + /// + /// Creates a new builder for MessagingExtensionResponse. + /// + public static MessageExtensionResponseBuilder CreateBuilder() + { + return new MessageExtensionResponseBuilder(); + } +} + + +/// +/// Messaging extension result. +/// +public class ComposeExtension +{ + /// + /// Type of result. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Layout for attachments. + /// See for common values. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + /// + /// Array of attachments (cards) to display. + /// + // TODO : there is an extra preview field but when is it used ? + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// + /// Text to display. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Activity preview for bot message preview. + /// + //TODO : this needs to be activity type or something else - format is type, attachments[] + [JsonPropertyName("activityPreview")] + public TeamsActivity? ActivityPreview { get; set; } + + /// + /// Suggested actions for config type. + /// + [JsonPropertyName("suggestedActions")] + public MessageExtensionSuggestedAction? SuggestedActions { get; set; } +} + +/// +/// Suggested actions for messaging extension configuration. +/// +public class MessageExtensionSuggestedAction +{ + //TODO : this should come from cards package + + /// + /// Array of actions. + /// + [JsonPropertyName("actions")] + public IList? Actions { get; set; } +} + + +/// +/// Builder for MessagingExtensionResponse. +/// +public class MessageExtensionResponseBuilder +{ + private string? _type; + private string? _attachmentLayout; + private TeamsAttachment[]? _attachments; + private TeamsActivity? _activityPreview; + private object[]? _suggestedActions; + private string? _text; + + /// + /// Sets the type of the response. Common values: "result", "auth", "config", "message", "botMessagePreview". + /// + public MessageExtensionResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the attachment layout. Common values: "list", "grid". + /// + public MessageExtensionResponseBuilder WithAttachmentLayout(string layout) + { + _attachmentLayout = layout; + return this; + } + + /// + /// Sets the attachments for the response. + /// + public MessageExtensionResponseBuilder WithAttachments(params TeamsAttachment[] attachments) + { + _attachments = attachments; + return this; + } + + /// + /// Sets the activity preview for bot message preview type. + /// + public MessageExtensionResponseBuilder WithActivityPreview(TeamsActivity activityPreview) + { + _activityPreview = activityPreview; + return this; + } + + /// + /// Sets suggested actions for config type. + /// + public MessageExtensionResponseBuilder WithSuggestedActions(params object[] actions) + { + _suggestedActions = actions; + return this; + } + + /// + /// Sets the text message for message type. + /// + public MessageExtensionResponseBuilder WithText(string text) + { + _text = text; + return this; + } + + /// + /// Validates and builds the MessagingExtensionResponse. + /// + internal MessageExtensionResponse Validate() + { + if (string.IsNullOrEmpty(_type)) + { + throw new InvalidOperationException("Type must be set. Use WithType() to specify MessageExtensionResponseType.Result, Message, BotMessagePreview, or Config."); + } + + return _type switch + { + MessageExtensionResponseType.Result => ValidateResultType(), + MessageExtensionResponseType.Message => ValidateMessageType(), + MessageExtensionResponseType.BotMessagePreview => ValidateBotMessagePreviewType(), + MessageExtensionResponseType.Config => ValidateConfigType(), + _ => throw new InvalidOperationException($"Unknown message extension response type: {_type}") + }; + } + + private MessageExtensionResponse ValidateResultType() + { + if (_attachments == null || _attachments.Length == 0) + { + throw new InvalidOperationException("Attachments must be set for Result type. Use WithAttachments()."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for Result type. Text is only used with Message type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Result type. ActivityPreview is only used with BotMessagePreview type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for Result type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + AttachmentLayout = _attachmentLayout, + Attachments = _attachments + } + }; + } + + private MessageExtensionResponse ValidateMessageType() + { + if (string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text must be set for Message type. Use WithText()."); + } + + if (_attachments != null) + { + throw new InvalidOperationException("Attachments cannot be set for Message type. Attachments is only used with Result or BotMessagePreview type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for Message type. AttachmentLayout is only used with Result type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Message type. ActivityPreview is only used with BotMessagePreview type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for Message type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + Text = _text + } + }; + } + + private MessageExtensionResponse ValidateBotMessagePreviewType() + { + if (_activityPreview == null) + { + throw new InvalidOperationException("ActivityPreview must be set for BotMessagePreview type. Use WithActivityPreview()."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for BotMessagePreview type. Text is only used with Message type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for BotMessagePreview type. AttachmentLayout is only used with Result type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for BotMessagePreview type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + ActivityPreview = _activityPreview, + Attachments = _attachments + } + }; + } + + private MessageExtensionResponse ValidateConfigType() + { + if (_suggestedActions == null || _suggestedActions.Length == 0) + { + throw new InvalidOperationException("SuggestedActions must be set for Config type. Use WithSuggestedActions()."); + } + + if (_attachments != null) + { + throw new InvalidOperationException("Attachments cannot be set for Config type. Attachments is only used with Result or BotMessagePreview type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for Config type. AttachmentLayout is only used with Result type."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for Config type. Text is only used with Message type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Config type. ActivityPreview is only used with BotMessagePreview type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + SuggestedActions = new MessageExtensionSuggestedAction { Actions = _suggestedActions } + } + }; + } + + /// + /// Builds the MessagingExtensionResponse and wraps it in a InvokeResponse. + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleRequest.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleRequest.cs new file mode 100644 index 00000000..d0941b8d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleRequest.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Task module invoke request value payload. +/// +public class TaskModuleRequest +{ + /// + /// User input data. Free payload with key-value pairs. + /// + [JsonPropertyName("data")] + public object? Data { get; set; } + + /// + /// Current user context, i.e., the current theme. + /// + [JsonPropertyName("context")] + public TaskModuleRequestContext? Context { get; set; } +} + +/// +/// Current user context, i.e., the current theme. +/// +public class TaskModuleRequestContext +{ + /// + /// The user's current theme. + /// + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleResponse.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleResponse.cs new file mode 100644 index 00000000..e81ac1a6 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Invokes/TaskModuleResponse.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Task module response types. +/// +public static class TaskModuleResponseType +{ + /// + /// Continue type - displays a card or URL in the task module. + /// + public const string Continue = "continue"; + + /// + /// Message type - displays a plain text message. + /// + public const string Message = "message"; +} + +/// +/// Task module size constants. +/// +public static class TaskModuleSize +{ + /// + /// Small size. + /// + public const string Small = "small"; + + /// + /// Medium size. + /// + public const string Medium = "medium"; + + /// + /// Large size. + /// + public const string Large = "large"; +} + +/// +/// Task module response wrapper. +/// +public class TaskModuleResponse +{ + /// + /// The task module result. + /// + [JsonPropertyName("task")] + public Response? Task { get; set; } + + /// + /// Creates a new builder for TaskModuleResponse. + /// + public static TaskModuleResponseBuilder CreateBuilder() + { + return new TaskModuleResponseBuilder(); + } +} + +/// +/// Builder for TaskModuleResponse. +/// +public class TaskModuleResponseBuilder +{ + private string? _type; + private string? _title; + private object? _card; + private object _height = TaskModuleSize.Small; + private object _width = TaskModuleSize.Small; + private string? _message; + //private string? _url; + //private string? _fallbackUrl; + //private string? _completionBotId; + + /// + /// Sets the type of the response. Use TaskModuleResponseType constants. + /// + public TaskModuleResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the title of the task module. + /// + public TaskModuleResponseBuilder WithTitle(string title) + { + _title = title; + return this; + } + + /// + /// Sets the card content for continue type. + /// + public TaskModuleResponseBuilder WithCard(object card) + { + _card = card; + return this; + } + + /// + /// Sets the height. Can be a number (pixels) or use TaskModuleSize constants. + /// + public TaskModuleResponseBuilder WithHeight(object height) + { + _height = height; + return this; + } + + /// + /// Sets the width. Can be a number (pixels) or use TaskModuleSize constants. + /// + public TaskModuleResponseBuilder WithWidth(object width) + { + _width = width; + return this; + } + + /// + /// Sets the message for message type. + /// + public TaskModuleResponseBuilder WithMessage(string message) + { + _message = message; + return this; + } + + /* + /// + /// Sets the URL for continue type. + /// + public TaskModuleResponseBuilder WithUrl(string url) + { + _url = url; + return this; + } + + /// + /// Sets the fallback URL if the card cannot be displayed. + /// + public TaskModuleResponseBuilder WithFallbackUrl(string fallbackUrl) + { + _fallbackUrl = fallbackUrl; + return this; + } + + /// + /// Sets the completion bot ID. + /// + public TaskModuleResponseBuilder WithCompletionBotId(string completionBotId) + { + _completionBotId = completionBotId; + return this; + } + */ + + /// + /// Builds the TaskModuleResponse. + /// + internal TaskModuleResponse Validate() + { + if (string.IsNullOrEmpty(_type)) + { + throw new InvalidOperationException("Type must be set. Use WithType() to specify TaskModuleResponseType.Continue or TaskModuleResponseType.Message."); + } + + object? value = _type switch + { + TaskModuleResponseType.Continue => ValidateContinueType(), + TaskModuleResponseType.Message => ValidateMessageType(), + _ => throw new InvalidOperationException($"Unknown task module response type: {_type}") + }; + + return new TaskModuleResponse + { + Task = new Response + { + Type = _type, + Value = value + } + }; + } + + private object ValidateContinueType() + { + if (_card == null) + { + throw new InvalidOperationException("Card must be set for Continue type. Use WithCard()."); + } + + if (!string.IsNullOrEmpty(_message)) + { + throw new InvalidOperationException("Message cannot be set for Continue type. Message is only used with Message type."); + } + + return new + { + title = _title, + height = _height, + width = _width, + card = _card, + //url = _url, + //fallbackUrl = _fallbackUrl, + //completionBotId = _completionBotId + }; + } + + private string ValidateMessageType() + { + if (string.IsNullOrEmpty(_message)) + { + throw new InvalidOperationException("Message must be set for Message type. Use WithMessage()."); + } + + if (!string.IsNullOrEmpty(_title)) + { + throw new InvalidOperationException("Title cannot be set for Message type. Title is only used with Continue type."); + } + + if (_card != null) + { + throw new InvalidOperationException("Card cannot be set for Message type. Card is only used with Continue type."); + } + + return _message; + } + + /// + /// Builds the TaskModuleResponse and wraps it in a InvokeResponse. + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} + +/// +/// Task module result. +/// +public class Response +{ + /// + /// Type of result. + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// Value + /// + [JsonPropertyName("value")] + public object? Value { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs new file mode 100644 index 00000000..8293525b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Team.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a team, including its identity, group association, and membership details. +/// +public class Team +{ + /// + /// Represents the unique identifier of the team. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Azure Active Directory (AAD) Group ID associated with the team. + /// + [JsonPropertyName("aadGroupId")] public string? AadGroupId { get; set; } + + /// + /// Gets or sets the unique identifier of the tenant associated with this entity. + /// + [JsonPropertyName("tenantId")] public string? TenantId { get; set; } + + /// + /// Gets or sets the type identifier for the object represented by this instance. + /// + [JsonPropertyName("type")] public string? Type { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public string? Name { get; set; } + + /// + /// Number of channels in the team. + /// + [JsonPropertyName("channelCount")] public int? ChannelCount { get; set; } + + /// + /// Number of members in the team. + /// + [JsonPropertyName("memberCount")] public int? MemberCount { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs new file mode 100644 index 00000000..2bedb799 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Teams Activity schema. +/// +public class TeamsActivity : CoreActivity +{ + /// + /// Creates a new instance of the TeamsActivity class from the specified Activity object. + /// + /// The Activity instance to convert. Cannot be null. + /// A TeamsActivity object that represents the specified Activity. + public static TeamsActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + return TeamsActivityType.ActivityDeserializerMap.TryGetValue(activity.Type, out Func? factory) + ? factory(activity) + : new TeamsActivity(activity); // Fallback to base type + } + + /// + /// Overrides the ToJson method to serialize the TeamsActivity object to a JSON string. + /// Uses the appropriate JSON type info based on the activity type. + /// + /// A JSON string representation of the activity using the type-specific serializer. + public override string ToJson() + { + return Type == TeamsActivityType.Message + ? ToJson(TeamsActivityJsonContext.Default.MessageActivity) + : ToJson(TeamsActivityJsonContext.Default.TeamsActivity); // Fallback to base type + } + + /// + /// Constructor with type parameter. + /// + /// + protected TeamsActivity(string type) : this() + { + Type = type; + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public TeamsActivity() + { + From = new TeamsConversationAccount(); + Recipient = new TeamsConversationAccount(); + Conversation = new TeamsConversation(); + } + + /// + /// Protected constructor to create TeamsActivity from CoreActivity. + /// Allows derived classes to call via base(activity). + /// + /// The CoreActivity to convert. + protected TeamsActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + // Convert base types to Teams-specific types + if (activity.ChannelData is not null) + { + ChannelData = new TeamsChannelData(activity.ChannelData); + } + + if (activity.From is not null) + { + From = TeamsConversationAccount.FromConversationAccount(activity.From); + } + + if (activity.Recipient is not null) + { + Recipient = TeamsConversationAccount.FromConversationAccount(activity.Recipient); + } + + if (activity.Conversation is not null) + { + Conversation = TeamsConversation.FromConversation(activity.Conversation); + } + Attachments = TeamsAttachment.FromJArray(activity.Attachments); + Entities = EntityList.FromJsonArray(activity.Entities); + + Rebase(); + } + + /// + /// Resets shadow properties in base class + /// + /// + internal TeamsActivity Rebase() + { + base.Attachments = Attachments?.ToJsonArray(); + base.Entities = Entities?.ToJsonArray(); + + return this; + } + + + /// + /// Gets or sets the account information for the sender of the Teams conversation. + /// + [JsonPropertyName("from")] + public new TeamsConversationAccount? From + { + get => base.From as TeamsConversationAccount; + set => base.From = value; + } + + /// + /// Gets or sets the account information for the recipient of the Teams conversation. + /// + [JsonPropertyName("recipient")] + public new TeamsConversationAccount? Recipient + { + get => base.Recipient as TeamsConversationAccount; + set => base.Recipient = value; + } + + /// + /// Gets or sets the conversation information for the Teams conversation. + /// + [JsonPropertyName("conversation")] + public new TeamsConversation? Conversation + { + get => base.Conversation as TeamsConversation; + set => base.Conversation = value; + } + + /// + /// Gets or sets the Teams-specific channel data associated with this activity. + /// + [JsonPropertyName("channelData")] + public new TeamsChannelData? ChannelData + { + get => base.ChannelData as TeamsChannelData; + set => base.ChannelData = value; + } + + /// + /// Gets or sets the entities specific to Teams. + /// + [JsonPropertyName("entities")] public new EntityList? Entities { get; set; } + + /// + /// Attachments specific to Teams. + /// + [JsonPropertyName("attachments")] public new IList? Attachments { get; set; } + + /// + /// UTC timestamp of when the activity was sent. + /// + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + /// + /// Local timestamp of when the activity was sent, including timezone offset. + /// + [JsonPropertyName("localTimestamp")] + public string? LocalTimestamp { get; set; } + + /// + /// Locale of the activity set by the client (e.g., "en-US"). + /// + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + /// + /// Local timezone of the client (e.g., "America/Los_Angeles"). + /// + [JsonPropertyName("localTimezone")] + public string? LocalTimezone { get; set; } + + /// + /// Adds an entity to the activity's Entities collection. + /// + /// + /// + public TeamsActivity AddEntity(Entity entity) + { + // TODO: Pick up nuances about entities. + // For eg, there can only be 1 single MessageEntity + Entities ??= []; + Entities.Add(entity); + return this; + } + + /// + /// Creates a new TeamsActivityBuilder instance for building a TeamsActivity with a fluent API. + /// + /// A new TeamsActivityBuilder instance. + public static new TeamsActivityBuilder CreateBuilder() => new(); + + /// + /// Creates a new TeamsActivityBuilder instance initialized with the specified TeamsActivity. + /// + /// + /// + public static TeamsActivityBuilder CreateBuilder(TeamsActivity activity) => new(activity); + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs new file mode 100644 index 00000000..89dd1ba3 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides a fluent API for building TeamsActivity instances. +/// +public class TeamsActivityBuilder : CoreActivityBuilder +{ + /// + /// Initializes a new instance of the TeamsActivityBuilder class. + /// + internal TeamsActivityBuilder() : base(TeamsActivity.FromActivity(new CoreActivity())) + { + } + + /// + /// Initializes a new instance of the TeamsActivityBuilder class with an existing activity. + /// + /// The activity to build upon. + internal TeamsActivityBuilder(TeamsActivity activity) : base(activity) + { + } + + /// + /// Sets the conversation (override for Teams-specific type). + /// + protected override void SetConversation(Conversation? conversation) + { + _activity.Conversation = conversation is TeamsConversation teamsConv + ? teamsConv + : TeamsConversation.FromConversation(conversation); + } + + /// + /// Sets the From account (override for Teams-specific type). + /// + protected override void SetFrom(ConversationAccount? from) + { + _activity.From = from is TeamsConversationAccount teamsAccount + ? teamsAccount + : TeamsConversationAccount.FromConversationAccount(from); + } + + /// + /// Sets the Recipient account (override for Teams-specific type). + /// + protected override void SetRecipient(ConversationAccount? recipient) + { + _activity.Recipient = recipient is TeamsConversationAccount teamsAccount + ? teamsAccount + : TeamsConversationAccount.FromConversationAccount(recipient); + } + + /// + /// Sets the Teams-specific channel data. + /// + /// The channel data. + /// The builder instance for chaining. + public TeamsActivityBuilder WithChannelData(TeamsChannelData? channelData) + { + _activity.ChannelData = channelData; + return this; + } + + /// + /// Sets the entities collection. + /// + /// The entities collection. + /// The builder instance for chaining. + public TeamsActivityBuilder WithEntities(EntityList entities) + { + _activity.Entities = entities; + return this; + } + + /// + /// Sets the attachments collection. + /// + /// The attachments collection. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAttachments(IList attachments) + { + _activity.Attachments = attachments; + return this; + } + + // TODO: Builders should only have "With" methods, not "Add" methods. + /// + /// Replaces the attachments collection with a single attachment. + /// + /// The attachment to set. Passing null clears the attachments. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAttachment(TeamsAttachment? attachment) + { + _activity.Attachments = attachment is null + ? null + : [attachment]; + + return this; + } + + /// + /// Adds an entity to the activity's Entities collection. + /// + /// The entity to add. + /// The builder instance for chaining. + public TeamsActivityBuilder AddEntity(Entity entity) + { + _activity.Entities ??= []; + _activity.Entities.Add(entity); + return this; + } + + /// + /// Adds an attachment to the activity's Attachments collection. + /// + /// The attachment to add. + /// The builder instance for chaining. + public TeamsActivityBuilder AddAttachment(TeamsAttachment attachment) + { + _activity.Attachments ??= []; + _activity.Attachments.Add(attachment); + return this; + } + + /// + /// Adds an Adaptive Card attachment to the activity. + /// + /// The Adaptive Card payload. + /// Optional callback to further configure the attachment before it is added. + /// The builder instance for chaining. + public TeamsActivityBuilder AddAdaptiveCardAttachment(object adaptiveCard, Action? configure = null) + { + TeamsAttachment attachment = BuildAdaptiveCardAttachment(adaptiveCard, configure); + return AddAttachment(attachment); + } + + /// + /// Sets the activity attachments collection to a single Adaptive Card attachment. + /// + /// The Adaptive Card payload. + /// Optional callback to further configure the attachment. + /// The builder instance for chaining. + public TeamsActivityBuilder WithAdaptiveCardAttachment(object adaptiveCard, Action? configure = null) + { + TeamsAttachment attachment = BuildAdaptiveCardAttachment(adaptiveCard, configure); + return WithAttachment(attachment); + } + + /// + /// Adds or sets the text content of the activity. + /// + /// + /// + /// + public TeamsActivityBuilder WithText(string text, string textFormat = "plain") + { + WithProperty("text", text); + WithProperty("textFormat", textFormat); + return this; + } + + /// + /// Adds a mention to the activity. + /// + /// The account to mention. + /// Optional custom text for the mention. If null, uses the account name. + /// Whether to prepend the mention text to the activity's text content. + /// The builder instance for chaining. + public TeamsActivityBuilder AddMention(ConversationAccount account, string? text = null, bool addText = true) + { + ArgumentNullException.ThrowIfNull(account); + string? mentionText = text ?? account.Name; + + if (addText) + { + string? currentText = _activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + WithProperty("text", $"{mentionText} {currentText}"); + } + + _activity.Entities ??= []; + _activity.Entities.Add(new MentionEntity(account, $"{mentionText}")); + + CoreActivity baseActivity = _activity; + baseActivity.Entities = _activity.Entities.ToJsonArray(); + + return this; + } + + /// + /// Builds and returns the configured TeamsActivity instance. + /// + /// The configured TeamsActivity. + public override TeamsActivity Build() + { + _activity.Rebase(); + return _activity; + } + + private static TeamsAttachment BuildAdaptiveCardAttachment(object adaptiveCard, Action? configure) + { + ArgumentNullException.ThrowIfNull(adaptiveCard); + + TeamsAttachmentBuilder attachmentBuilder = TeamsAttachment + .CreateBuilder() + .WithAdaptiveCard(adaptiveCard); + + configure?.Invoke(attachmentBuilder); + + return attachmentBuilder.Build(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs new file mode 100644 index 00000000..2f621d27 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Json source generator context for Teams activity types. +/// +[JsonSourceGenerationOptions( + WriteIndented = true, + IncludeFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(TeamsActivity))] +[JsonSerializable(typeof(MessageActivity))] +[JsonSerializable(typeof(Entity))] +[JsonSerializable(typeof(EntityList))] +[JsonSerializable(typeof(MentionEntity))] +[JsonSerializable(typeof(ClientInfoEntity))] +[JsonSerializable(typeof(OMessageEntity))] +[JsonSerializable(typeof(SensitiveUsageEntity))] +[JsonSerializable(typeof(DefinedTerm))] +[JsonSerializable(typeof(ProductInfoEntity))] +[JsonSerializable(typeof(StreamInfoEntity))] +[JsonSerializable(typeof(CitationEntity))] +[JsonSerializable(typeof(CitationClaim))] +[JsonSerializable(typeof(CitationAppearanceDocument))] +[JsonSerializable(typeof(CitationImageObject))] +[JsonSerializable(typeof(CitationAppearance))] +[JsonSerializable(typeof(TeamsChannelData))] +[JsonSerializable(typeof(ConversationAccount))] +[JsonSerializable(typeof(TeamsConversationAccount))] +[JsonSerializable(typeof(TeamsConversation))] +[JsonSerializable(typeof(ExtendedPropertiesDictionary))] +[JsonSerializable(typeof(TeamsAttachment))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonObject))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonNode))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonArray))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonValue))] +[JsonSerializable(typeof(System.Int32))] +[JsonSerializable(typeof(System.Boolean))] +[JsonSerializable(typeof(System.Int64))] +[JsonSerializable(typeof(System.Double))] +public partial class TeamsActivityJsonContext : JsonSerializerContext +{ +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs new file mode 100644 index 00000000..c7c1df97 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityType.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides constant values for activity types used in Microsoft Teams bot interactions. +/// +/// These activity type constants are used to identify the type of activity received or sent in a Teams +/// bot context. Use these values when handling or generating activities to ensure compatibility with the Teams +/// platform. +public static class TeamsActivityType +{ + + /// + /// Represents the default message string used for communication or display purposes. + /// + public const string Message = ActivityType.Message; + /// + /// Represents a typing indicator activity. + /// + public const string Typing = ActivityType.Typing; + + /// + /// Represents a message reaction activity. + /// + public const string MessageReaction = "messageReaction"; + /// + /// Represents a message update activity. + /// + public const string MessageUpdate = "messageUpdate"; + /// + /// Represents a message delete activity. + /// + public const string MessageDelete = "messageDelete"; + + /// + /// Represents a conversation update activity. + /// + public const string ConversationUpdate = "conversationUpdate"; + + /* + /// + /// Represents an end of conversation activity. + /// + public const string EndOfConversation = "endOfConversation"; + */ + + /// + /// Represents an installation update activity. + /// + public const string InstallationUpdate = "installationUpdate"; + + /// + /// Represents the string value "invoke" used to identify an invoke operation or action. + /// + public const string Invoke = "invoke"; + + /// + /// Represents an event activity. + /// + public const string Event = "event"; + + //TODO : review command activity + /* + /// + /// Represents a command activity. + /// + public const string Command = "command"; + + /// + /// Represents a command result activity. + /// + public const string CommandResult = "commandResult"; + */ + + /// + /// Registry of activity type factories for creating specialized activity instances. + /// + internal static readonly Dictionary> ActivityDeserializerMap = new() + { + [Message] = MessageActivity.FromActivity, + [MessageReaction] = MessageReactionActivity.FromActivity, + [MessageUpdate] = MessageUpdateActivity.FromActivity, + [MessageDelete] = MessageDeleteActivity.FromActivity, + [ConversationUpdate] = ConversationUpdateActivity.FromActivity, + //[TeamsActivityType.EndOfConversation] = EndOfConversationActivity.FromActivity, + [InstallationUpdate] = InstallUpdateActivity.FromActivity, + [Invoke] = InvokeActivity.FromActivity, + [Event] = EventActivity.FromActivity + }; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs new file mode 100644 index 00000000..6832acbe --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachment.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Teams attachment content types. +/// +public static class AttachmentContentType +{ + /// + /// Adaptive Card content type. + /// + public const string AdaptiveCard = "application/vnd.microsoft.card.adaptive"; + + /// + /// Hero Card content type. + /// + public const string HeroCard = "application/vnd.microsoft.card.hero"; + + /// + /// Thumbnail Card content type. + /// + public const string ThumbnailCard = "application/vnd.microsoft.card.thumbnail"; + + /// + /// Office 365 Connector Card content type. + /// + public const string O365ConnectorCard = "application/vnd.microsoft.teams.card.o365connector"; + + /// + /// File consent card content type. + /// + public const string FileConsentCard = "application/vnd.microsoft.teams.card.file.consent"; + + /// + /// File info card content type. + /// + public const string FileInfoCard = "application/vnd.microsoft.teams.card.file.info"; + + //TODO : verify these + /* + /// + /// Receipt Card content type. + /// + public const string ReceiptCard = "application/vnd.microsoft.card.receipt"; + + /// + /// Signin Card content type. + /// + public const string SigninCard = "application/vnd.microsoft.card.signin"; + + /// + /// Animation content type. + /// + public const string Animation = "application/vnd.microsoft.card.animation"; + + /// + /// Audio content type. + /// + public const string Audio = "application/vnd.microsoft.card.audio"; + + /// + /// Video content type. + /// + public const string Video = "application/vnd.microsoft.card.video"; + */ +} + +/// +/// Attachment layout types. +/// +public static class TeamsAttachmentLayout +{ + /// + /// List layout - displays attachments in a vertical list. + /// + public const string List = "list"; + + /// + /// Grid layout - displays attachments in a grid. + /// + public const string Grid = "grid"; + + /// + /// Carousel layout - displays attachments in a horizontal carousel. + /// + public const string Carousel = "carousel"; +} + +/// +/// Extension methods for TeamsAttachment. +/// +public static class TeamsAttachmentExtensions +{ + static internal JsonArray ToJsonArray(this IList attachments) + { + JsonArray jsonArray = []; + foreach (TeamsAttachment attachment in attachments) + { + JsonNode jsonNode = JsonSerializer.SerializeToNode(attachment)!; + jsonArray.Add(jsonNode); + } + return jsonArray; + } +} + +/// +/// Teams attachment model. +/// +public class TeamsAttachment +{ + static internal IList? FromJArray(JsonArray? jsonArray) + { + if (jsonArray is null) + { + return null; + } + List attachments = []; + foreach (JsonNode? item in jsonArray) + { + attachments.Add(item.Deserialize()!); + } + return attachments; + } + + /// + /// Content of the attachment. + /// + [JsonPropertyName("contentType")] public string ContentType { get; set; } = string.Empty; + + /// + /// Content URL of the attachment. + /// + [JsonPropertyName("contentUrl")] public Uri? ContentUrl { get; set; } + + /// + /// Content for the Attachment + /// + [JsonPropertyName("content")] public object? Content { get; set; } + + /// + /// Gets or sets the name of the attachment. + /// + [JsonPropertyName("name")] public string? Name { get; set; } + + /// + /// Gets or sets the thumbnail URL of the attachment. + /// + [JsonPropertyName("thumbnailUrl")] public Uri? ThumbnailUrl { get; set; } + + /// + /// Extension data for additional properties not explicitly defined by the type. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// Creates a builder for constructing a instance. + /// + public static TeamsAttachmentBuilder CreateBuilder() => new(); + + /// + /// Creates a builder initialized with an existing instance. + /// + /// The attachment to wrap. + public static TeamsAttachmentBuilder CreateBuilder(TeamsAttachment attachment) => new(attachment); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs new file mode 100644 index 00000000..19cbec22 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsAttachmentBuilder.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Provides a fluent API for creating instances. +/// +public class TeamsAttachmentBuilder +{ + private const string AdaptiveCardContentType = "application/vnd.microsoft.card.adaptive"; + + private readonly TeamsAttachment _attachment; + + internal TeamsAttachmentBuilder() : this(new TeamsAttachment()) + { + } + + internal TeamsAttachmentBuilder(TeamsAttachment attachment) + { + _attachment = attachment ?? throw new ArgumentNullException(nameof(attachment)); + } + + /// + /// Sets the content type for the attachment. + /// + public TeamsAttachmentBuilder WithContentType(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + throw new ArgumentException("Content type cannot be null or whitespace.", nameof(contentType)); + } + + _attachment.ContentType = contentType; + return this; + } + + /// + /// Sets the payload for the attachment. + /// + public TeamsAttachmentBuilder WithContent(object? content) + { + _attachment.Content = content; + return this; + } + + /// + /// Sets the content url for the attachment. + /// + public TeamsAttachmentBuilder WithContentUrl(Uri? contentUrl) + { + _attachment.ContentUrl = contentUrl; + return this; + } + + /// + /// Sets the friendly name for the attachment. + /// + public TeamsAttachmentBuilder WithName(string? name) + { + _attachment.Name = name; + return this; + } + + /// + /// Sets the thumbnail url for the attachment. + /// + public TeamsAttachmentBuilder WithThumbnailUrl(Uri? thumbnailUrl) + { + _attachment.ThumbnailUrl = thumbnailUrl; + return this; + } + + /// + /// Adds or updates an extension property on the attachment. + /// Passing a null value removes the property. + /// + public TeamsAttachmentBuilder WithProperty(string propertyName, object? value) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + } + + if (value is null) + { + _attachment.Properties.Remove(propertyName); + } + else + { + _attachment.Properties[propertyName] = value; + } + + return this; + } + + /// + /// Configures the attachment to contain an Adaptive Card payload. + /// + public TeamsAttachmentBuilder WithAdaptiveCard(object adaptiveCard) + { + ArgumentNullException.ThrowIfNull(adaptiveCard); + _attachment.ContentType = AdaptiveCardContentType; + _attachment.Content = adaptiveCard; + _attachment.ContentUrl = null; + return this; + } + + /// + /// Builds the attachment. + /// + public TeamsAttachment Build() => _attachment; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs new file mode 100644 index 00000000..2c85d9c0 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannel.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a Microsoft Teams channel, including its identifier, type, and display name. +/// +/// This class is typically used to serialize or deserialize channel information when interacting with +/// Microsoft Teams APIs or webhooks. All properties are optional and may be null if the corresponding data is not +/// available. +public class TeamsChannel +{ + /// + /// Represents the unique identifier of the channel. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Azure Active Directory (AAD) Object ID associated with the channel. + /// + [JsonPropertyName("aadObjectId")] public string? AadObjectId { get; set; } + + /// + /// Type identifier for the channel. + /// + [JsonPropertyName("type")] public string? Type { get; set; } + + /// + /// Gets or sets the name associated with the object. + /// + [JsonPropertyName("name")] public string? Name { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs new file mode 100644 index 00000000..ea5d7d22 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsChannelData.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents the source of a Teams activity. +/// +public class TeamsChannelDataSource +{ + /// + /// The name of the source. + /// + [JsonPropertyName("name")] public string? Name { get; set; } +} + +/// +/// Tenant information for Teams channel data. +/// +public class TeamsChannelDataTenant +{ + /// + /// Unique identifier of the tenant. + /// + [JsonPropertyName("id")] public string? Id { get; set; } +} + +/// +/// Teams channel data settings. +/// +public class TeamsChannelDataSettings +{ + /// + /// Selected channel information. + /// + [JsonPropertyName("selectedChannel")] public required TeamsChannel SelectedChannel { get; set; } + + /// + /// Gets or sets the collection of additional properties not explicitly defined by the type. + /// + /// This property stores extra JSON fields encountered during deserialization that do not map to + /// known properties. It enables round-tripping of unknown or custom data without loss. The dictionary keys + /// correspond to the property names in the JSON payload. + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +} + +/// +/// Represents Teams-specific channel data. +/// +public class TeamsChannelData : ChannelData +{ + /// + /// Creates a new instance of the class. + /// + public TeamsChannelData() + { + } + + /// + /// Creates a new instance of the class from the specified object. + /// + /// + public TeamsChannelData(ChannelData? cd) + { + if (cd is not null) + { + //TODO : is channel id needed ? what is teamschannleid and teamsteamid ? + if (cd.Properties.TryGetValue("teamsChannelId", out object? channelIdObj) + && channelIdObj is JsonElement jeChannelId + && jeChannelId.ValueKind == JsonValueKind.String) + { + TeamsChannelId = jeChannelId.GetString(); + } + + if (cd.Properties.TryGetValue("teamsTeamId", out object? teamIdObj) + && teamIdObj is JsonElement jeTeamId + && jeTeamId.ValueKind == JsonValueKind.String) + { + TeamsTeamId = jeTeamId.GetString(); + } + + if (cd.Properties.TryGetValue("settings", out object? settingsObj) + && settingsObj is JsonElement settingsObjJE + && settingsObjJE.ValueKind == JsonValueKind.Object) + { + Settings = JsonSerializer.Deserialize(settingsObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("channel", out object? channelObj) + && channelObj is JsonElement channelObjJE + && channelObjJE.ValueKind == JsonValueKind.Object) + { + Channel = JsonSerializer.Deserialize(channelObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("tenant", out object? tenantObj) + && tenantObj is JsonElement je + && je.ValueKind == JsonValueKind.Object) + { + Tenant = JsonSerializer.Deserialize(je.GetRawText()); + } + + if (cd.Properties.TryGetValue("eventType", out object? eventTypeObj) + && eventTypeObj is JsonElement jeEventType + && jeEventType.ValueKind == JsonValueKind.String) + { + EventType = jeEventType.GetString(); + } + + if (cd.Properties.TryGetValue("team", out object? teamObj) + && teamObj is JsonElement teamObjJE + && teamObjJE.ValueKind == JsonValueKind.Object) + { + Team = JsonSerializer.Deserialize(teamObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("source", out object? sourceObj) + && sourceObj is JsonElement sourceObjJE + && sourceObjJE.ValueKind == JsonValueKind.Object) + { + Source = JsonSerializer.Deserialize(sourceObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("feedbackLoopEnabled", out object? feedbackObj) + && feedbackObj is JsonElement jeFeedback + && jeFeedback.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + FeedbackLoopEnabled = jeFeedback.GetBoolean(); + } + } + } + + + /// + /// Settings for the Teams channel. + /// + [JsonPropertyName("settings")] public TeamsChannelDataSettings? Settings { get; set; } + + /// + /// Gets or sets the unique identifier of the Microsoft Teams channel associated with this entity. + /// + [JsonPropertyName("teamsChannelId")] public string? TeamsChannelId { get; set; } + + /// + /// Teams Team Id. + /// + [JsonPropertyName("teamsTeamId")] public string? TeamsTeamId { get; set; } + + /// + /// Gets or sets the channel information associated with this entity. + /// + [JsonPropertyName("channel")] public TeamsChannel? Channel { get; set; } + + /// + /// Team information. + /// + [JsonPropertyName("team")] public Team? Team { get; set; } + + /// + /// Tenant information. + /// + [JsonPropertyName("tenant")] public TeamsChannelDataTenant? Tenant { get; set; } + + /// + /// Gets or sets the event type for conversation updates. See for known values. + /// + [JsonPropertyName("eventType")] public string? EventType { get; set; } + + /// + /// Source information for the activity. + /// + [JsonPropertyName("source")] public TeamsChannelDataSource? Source { get; set; } + + /// + /// Gets or sets whether the feedback loop (thumbs up/down) is enabled for the activity. + /// + [JsonPropertyName("feedbackLoopEnabled")] public bool? FeedbackLoopEnabled { get; set; } + +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs new file mode 100644 index 00000000..1e5ee878 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversation.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Defines known conversation types for Teams. +/// +public static class ConversationType +{ + /// + /// One-to-one conversation between a user and a bot. + /// + public const string Personal = "personal"; + + /// + /// Group chat conversation. + /// + public const string GroupChat = "groupChat"; + + /// + /// Channel conversation + /// + public const string Channel = "channel"; +} + +/// +/// Teams Conversation schema. +/// +public class TeamsConversation : Conversation +{ + /// + /// Initializes a new instance of the TeamsConversation class. + /// + [JsonConstructor] + public TeamsConversation() + { + } + + /// + /// Creates a Teams Conversation from a Conversation + /// + /// + /// + public static TeamsConversation? FromConversation(Conversation? conversation) + { + if (conversation is null) + { + return null; + } + TeamsConversation result = new(); + result.Id = conversation.Id; + if (conversation.Properties == null) + { + return result; + } + if (conversation.Properties.TryGetValue("tenantId", out object? tenantObj)) + { + result.TenantId = tenantObj?.ToString(); + } + if (conversation.Properties.TryGetValue("conversationType", out object? convTypeObj)) + { + result.ConversationType = convTypeObj?.ToString(); + } + if (conversation.Properties.TryGetValue("isGroup", out object? isGroupObj)) + { + result.IsGroup = Convert.ToBoolean(isGroupObj?.ToString()); + } + return result; + } + + /// + /// Tenant Id. + /// + [JsonPropertyName("tenantId")] public string? TenantId { get; set; } + + /// + /// Conversation Type. See for known values. + /// + [JsonPropertyName("conversationType")] public string? ConversationType { get; set; } + + /// + /// Indicates whether the conversation is a group conversation. + /// + [JsonPropertyName("isGroup")] public bool? IsGroup { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount.cs new file mode 100644 index 00000000..ef7b0bb4 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsConversationAccount.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a Microsoft Teams-specific conversation account, including Azure Active Directory (AAD) object +/// information. +/// +/// This class extends the base ConversationAccount to provide additional properties relevant to +/// Microsoft Teams, such as the Azure Active Directory object ID. It is typically used when working with Teams +/// conversations to access Teams-specific metadata. +public class TeamsConversationAccount : ConversationAccount +{ + /// + /// Initializes a new instance of the TeamsConversationAccount class. + /// + [JsonConstructor] + public TeamsConversationAccount() + { + } + + /// + /// Initializes a new instance of the TeamsConversationAccount class using the specified conversation account. + /// + /// The ConversationAccount instance containing the conversation's identifier, name, and properties. Cannot be null. + public static TeamsConversationAccount? FromConversationAccount(ConversationAccount? conversationAccount) + { + if (conversationAccount is null) + { + return null; + } + TeamsConversationAccount result = new(); + result.Id = conversationAccount.Id; + result.Name = conversationAccount.Name; + result.Properties = conversationAccount.Properties; + return result; + } + + /// + /// Gets or sets the Azure Active Directory (AAD) Object ID associated with the conversation account. + /// + [JsonIgnore] + public string? AadObjectId + { + get => GetStringProperty("aadObjectId"); + set => Properties["aadObjectId"] = value; + } + + /// + /// Gets or sets given name part of the user name. + /// + [JsonIgnore] + public string? GivenName + { + get => GetStringProperty("givenName"); + set => Properties["givenName"] = value; + } + + /// + /// Gets or sets surname part of the user name. + /// + [JsonIgnore] + public string? Surname + { + get => GetStringProperty("surname"); + set => Properties["surname"] = value; + } + + /// + /// Gets or sets email Id of the user. + /// + [JsonIgnore] + public string? Email + { + get => GetStringProperty("email"); + set => Properties["email"] = value; + } + + /// + /// Gets or sets unique user principal name. + /// + [JsonIgnore] + public string? UserPrincipalName + { + get => GetStringProperty("userPrincipalName"); + set => Properties["userPrincipalName"] = value; + } + + /// + /// Gets or sets the UserRole. + /// + [JsonIgnore] + public string? UserRole + { + get => GetStringProperty("userRole"); + set => Properties["userRole"] = value; + } + + /// + /// Gets or sets the TenantId. + /// + [JsonIgnore] + public string? TenantId + { + get => GetStringProperty("tenantId"); + set => Properties["tenantId"] = value; + } + + private string? GetStringProperty(string key) + { + if (Properties.TryGetValue(key, out object? val) && val is JsonElement je && je.ValueKind == JsonValueKind.String) + { + return je.GetString(); + } + return val?.ToString(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs new file mode 100644 index 00000000..b554efce --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.Models.cs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Represents a list of channels in a team. +/// +public class ChannelList +{ + /// + /// Gets or sets the list of channel conversations. + /// + [JsonPropertyName("conversations")] + public IList? Channels { get; set; } +} + +/// +/// Represents detailed information about a team. +/// +public class TeamDetails +{ + /// + /// Gets or sets the unique identifier of the team. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the name of the team. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the Azure Active Directory group ID associated with the team. + /// + [JsonPropertyName("aadGroupId")] + public string? AadGroupId { get; set; } + + /// + /// Gets or sets the number of channels in the team. + /// + [JsonPropertyName("channelCount")] + public int? ChannelCount { get; set; } + + /// + /// Gets or sets the number of members in the team. + /// + [JsonPropertyName("memberCount")] + public int? MemberCount { get; set; } + + /// + /// Gets or sets the type of the team. Valid values are standard, sharedChannel and privateChannel. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// Represents information about a meeting. +/// +public class MeetingInfo +{ + ///// + ///// Gets or sets the unique identifier of the meeting. + ///// + //[JsonPropertyName("id")] + //public string? Id { get; set; } + + /// + /// Gets or sets the details of the meeting. + /// + [JsonPropertyName("details")] + public MeetingDetails? Details { get; set; } + + /// + /// Gets or sets the conversation associated with the meeting. + /// + [JsonPropertyName("conversation")] + public ConversationAccount? Conversation { get; set; } + + /// + /// Gets or sets the organizer of the meeting. + /// + [JsonPropertyName("organizer")] + public TeamsConversationAccount? Organizer { get; set; } +} + +/// +/// Represents detailed information about a meeting. +/// +public class MeetingDetails +{ + /// + /// Gets or sets the unique identifier of the meeting. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the Microsoft Graph resource ID of the meeting. + /// + [JsonPropertyName("msGraphResourceId")] + public string? MsGraphResourceId { get; set; } + + /// + /// Gets or sets the scheduled start time of the meeting. + /// + [JsonPropertyName("scheduledStartTime")] + public DateTimeOffset? ScheduledStartTime { get; set; } + + /// + /// Gets or sets the scheduled end time of the meeting. + /// + [JsonPropertyName("scheduledEndTime")] + public DateTimeOffset? ScheduledEndTime { get; set; } + + /// + /// Gets or sets the join URL of the meeting. + /// + [JsonPropertyName("joinUrl")] + public Uri? JoinUrl { get; set; } + + /// + /// Gets or sets the title of the meeting. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the type of the meeting. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// Represents a meeting participant with their details. +/// +public class MeetingParticipant +{ + /// + /// Gets or sets the user information. + /// + [JsonPropertyName("user")] + public ConversationAccount? User { get; set; } + + /// + /// Gets or sets the meeting information. + /// + [JsonPropertyName("meeting")] + public MeetingParticipantInfo? Meeting { get; set; } + + /// + /// Gets or sets the conversation information. + /// + [JsonPropertyName("conversation")] + public ConversationAccount? Conversation { get; set; } +} + +/// +/// Represents meeting-specific participant information. +/// +public class MeetingParticipantInfo +{ + /// + /// Gets or sets the role of the participant in the meeting. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets a value indicating whether the participant is in the meeting. + /// + [JsonPropertyName("inMeeting")] + public bool? InMeeting { get; set; } +} + +/// +/// Base class for meeting notifications. +/// +public abstract class MeetingNotificationBase +{ + /// + /// Gets or sets the type of the notification. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +/// +/// Represents a targeted meeting notification. +/// +public class TargetedMeetingNotification : MeetingNotificationBase +{ + /// + [JsonPropertyName("type")] + public override string Type => "targetedMeetingNotification"; + + /// + /// Gets or sets the value of the notification. + /// + [JsonPropertyName("value")] + public TargetedMeetingNotificationValue? Value { get; set; } +} + +/// +/// Represents the value of a targeted meeting notification. +/// +public class TargetedMeetingNotificationValue +{ + /// + /// Gets or sets the list of recipients for the notification. + /// + [JsonPropertyName("recipients")] + public IList? Recipients { get; set; } + + /// + /// Gets or sets the surface configurations for the notification. + /// + [JsonPropertyName("surfaces")] + public IList? Surfaces { get; set; } +} + +/// +/// Represents a surface for meeting notifications. +/// +public class MeetingNotificationSurface +{ + /// + /// Gets or sets the surface type (e.g., "meetingStage"). + /// + [JsonPropertyName("surface")] + public string? Surface { get; set; } + + /// + /// Gets or sets the content type of the notification. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Gets or sets the content of the notification. + /// + [JsonPropertyName("content")] + public object? Content { get; set; } +} + +/// +/// Response from sending a meeting notification. +/// +public class MeetingNotificationResponse +{ + /// + /// Gets or sets the list of recipients for whom the notification failed. + /// + [JsonPropertyName("recipientsFailureInfo")] + public IList? RecipientsFailureInfo { get; set; } +} + +/// +/// Information about a failed notification recipient. +/// +public class MeetingNotificationRecipientFailureInfo +{ + /// + /// Gets or sets the recipient ID. + /// + [JsonPropertyName("recipientMri")] + public string? RecipientMri { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + + /// + /// Gets or sets the failure reason. + /// + [JsonPropertyName("failureReason")] + public string? FailureReason { get; set; } +} + +/// +/// Represents a team member for batch operations. +/// +public class TeamMember +{ + /// + /// Creates a new instance of the class. + /// + public TeamMember() + { + } + + /// + /// Creates a new instance of the class with the specified ID. + /// + /// The member ID. + public TeamMember(string id) + { + Id = id; + } + + /// + /// Gets or sets the member ID. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Represents the state of a batch operation. +/// +public class BatchOperationState +{ + /// + /// Gets or sets the state of the operation. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// Gets or sets the status map containing the count of different statuses. + /// + [JsonPropertyName("statusMap")] + public BatchOperationStatusMap? StatusMap { get; set; } + + /// + /// Gets or sets the retry after date time. + /// + [JsonPropertyName("retryAfter")] + public DateTimeOffset? RetryAfter { get; set; } + + /// + /// Gets or sets the total entries count. + /// + [JsonPropertyName("totalEntriesCount")] + public int? TotalEntriesCount { get; set; } +} + +/// +/// Represents the status map for a batch operation. +/// +public class BatchOperationStatusMap +{ + /// + /// Gets or sets the count of successful entries. + /// + [JsonPropertyName("success")] + public int? Success { get; set; } + + /// + /// Gets or sets the count of failed entries. + /// + [JsonPropertyName("failed")] + public int? Failed { get; set; } + + /// + /// Gets or sets the count of throttled entries. + /// + [JsonPropertyName("throttled")] + public int? Throttled { get; set; } + + /// + /// Gets or sets the count of pending entries. + /// + [JsonPropertyName("pending")] + public int? Pending { get; set; } +} + +/// +/// Response containing failed entries from a batch operation. +/// +public class BatchFailedEntriesResponse +{ + /// + /// Gets or sets the continuation token for paging. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of failed entries. + /// + [JsonPropertyName("failedEntries")] + public IList? FailedEntries { get; set; } +} + +/// +/// Represents a failed entry in a batch operation. +/// +public class BatchFailedEntry +{ + /// + /// Gets or sets the ID of the failed entry. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// Request body for sending a message to a list of users. +/// +internal sealed class SendMessageToUsersRequest +{ + /// + /// Gets or sets the list of members. + /// + [JsonPropertyName("members")] + public IList? Members { get; set; } + + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Request body for sending a message to all users in a tenant. +/// +internal sealed class SendMessageToTenantRequest +{ + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Request body for sending a message to all users in a team. +/// +internal sealed class SendMessageToTeamRequest +{ + /// + /// Gets or sets the activity to send. + /// + [JsonPropertyName("activity")] + public object? Activity { get; set; } + + /// + /// Gets or sets the team ID. + /// + [JsonPropertyName("teamId")] + public string? TeamId { get; set; } + + /// + /// Gets or sets the tenant ID. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs new file mode 100644 index 00000000..d4a5a74a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsApiClient.cs @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +using CustomHeaders = Dictionary; + +/// +/// Provides methods for interacting with Teams-specific APIs. +/// +/// The HTTP client instance used to send requests to the Teams service. Must not be null. +/// The logger instance used for logging. Optional. +public class TeamsApiClient(HttpClient httpClient, ILogger logger = default!) +{ + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + internal const string TeamsHttpClientName = "TeamsAPXClient"; + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders DefaultCustomHeaders { get; } = []; + + #region Team Operations + + /// + /// Fetches the list of channels for a given team. + /// + /// The ID of the team. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the list of channels. + /// Thrown if the channel list could not be retrieved successfully. + public async Task FetchChannelListAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}/conversations"; + + logger?.LogTrace("Fetching channel list from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching channel list", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Fetches details related to a team. + /// + /// The ID of the team. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the team details. + /// Thrown if the team details could not be retrieved successfully. + public async Task FetchTeamDetailsAsync(string teamId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(teamId)}"; + + logger?.LogTrace("Fetching team details from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching team details", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Meeting Operations + + /// + /// Fetches information about a meeting. + /// + /// The ID of the meeting, encoded as a BASE64 string. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the meeting information. + /// Thrown if the meeting info could not be retrieved successfully. + public async Task FetchMeetingInfoAsync(string meetingId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}"; + + logger?.LogTrace("Fetching meeting info from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting info", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Fetches details for a meeting participant. + /// + /// The ID of the meeting. Cannot be null or whitespace. + /// The ID of the participant. Cannot be null or whitespace. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the participant details. + /// Thrown if the participant details could not be retrieved successfully. + public async Task FetchParticipantAsync(string meetingId, string participantId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentException.ThrowIfNullOrWhiteSpace(participantId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(participantId)}?tenantId={Uri.EscapeDataString(tenantId)}"; + + logger?.LogTrace("Fetching meeting participant from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting participant", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a notification to meeting participants. + /// + /// The ID of the meeting. Cannot be null or whitespace. + /// The notification to send. Cannot be null. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains information about failed recipients. + /// Thrown if the notification could not be sent successfully. + public async Task SendMeetingNotificationAsync(string meetingId, TargetedMeetingNotification notification, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(meetingId); + ArgumentNullException.ThrowIfNull(notification); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/notification"; + string body = JsonSerializer.Serialize(notification); + + logger?.LogTrace("Sending meeting notification to {Url}: {Notification}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending meeting notification", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Batch Message Operations + + /// + /// Sends a message to a list of Teams users. + /// + /// The activity to send. Cannot be null. + /// The list of team members to send the message to. Cannot be null or empty. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToListOfUsersAsync(CoreActivity activity, IList teamsMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(teamsMembers); + if (teamsMembers.Count == 0) + { + throw new ArgumentException("teamsMembers cannot be empty", nameof(teamsMembers)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/users/"; + SendMessageToUsersRequest request = new() + { + Members = teamsMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to list of users at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of users", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all users in a tenant. + /// + /// The activity to send. Cannot be null. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToAllUsersInTenantAsync(CoreActivity activity, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/tenant/"; + SendMessageToTenantRequest request = new() + { + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to all users in tenant at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in tenant", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all users in a team. + /// + /// The activity to send. Cannot be null. + /// The ID of the team. Cannot be null or whitespace. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToAllUsersInTeamAsync(CoreActivity activity, string teamId, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(teamId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/team/"; + SendMessageToTeamRequest request = new() + { + Activity = activity, + TeamId = teamId, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to all users in team at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in team", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to a list of Teams channels. + /// + /// The activity to send. Cannot be null. + /// The list of channels to send the message to. Cannot be null or empty. + /// The ID of the tenant. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation ID. + /// Thrown if the message could not be sent successfully. + public async Task SendMessageToListOfChannelsAsync(CoreActivity activity, IList channelMembers, string tenantId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(channelMembers); + if (channelMembers.Count == 0) + { + throw new ArgumentException("channelMembers cannot be empty", nameof(channelMembers)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/channels/"; + SendMessageToUsersRequest request = new() + { + Members = channelMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonSerializer.Serialize(request); + + logger?.LogTrace("Sending message to list of channels at {Url}: {Request}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of channels", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Batch Operation Management + + /// + /// Gets the state of a batch operation. + /// + /// The ID of the operation. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the operation state. + /// Thrown if the operation state could not be retrieved successfully. + public async Task GetOperationStateAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + logger?.LogTrace("Getting operation state from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting operation state", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the failed entries of a batch operation with error code and message. + /// + /// The ID of the operation. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional continuation token for pagination. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the failed entries. + /// Thrown if the failed entries could not be retrieved successfully. + public async Task GetPagedFailedEntriesAsync(string operationId, Uri serviceUrl, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/failedentries/{Uri.EscapeDataString(operationId)}"; + + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + logger?.LogTrace("Getting paged failed entries from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting paged failed entries", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Cancels a batch operation by its ID. + /// + /// The ID of the operation to cancel. Cannot be null or whitespace. + /// The service URL for the Teams service. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the operation could not be cancelled successfully. + public async Task CancelOperationAsync(string operationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + logger?.LogTrace("Cancelling operation at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "cancelling operation", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + #endregion + + #region Private Methods + + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; + + #endregion +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs new file mode 100644 index 00000000..2d43b5da --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.HostingExtensions.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Extension methods for . +/// +public static class TeamsBotApplicationHostingExtensions +{ + /// + /// Registers Teams bot application services with the specified service collection. + /// + /// This method provides a simplified way to configure Teams bot support by encapsulating the + /// necessary service registrations and configuration binding. + /// The service collection to which Teams bot application services will be added. Cannot be null. + /// The name of the configuration section containing Azure Active Directory settings. Defaults to "AzureAd" if not + /// specified. + /// The service collection with Teams bot application services registered. + public static IServiceCollection AddTeams(this IServiceCollection services, string sectionName = "AzureAd") + => AddTeamsBotApplication(services, sectionName); + + /// + /// Adds the Default TeamsBotApplication + /// + /// + /// + /// + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") + { + return AddTeamsBotApplication(services, sectionName); + } + + /// + /// Adds a custom TeamsBotApplication + /// + /// The WebApplicationBuilder instance. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The updated WebApplicationBuilder instance. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : TeamsBotApplication + { + // Register options to defer configuration reading until ServiceProvider is built + services.AddOptions() + .Configure((options, configuration) => + { + options.Scope = "https://api.botframework.com/.default"; + if (!string.IsNullOrEmpty(configuration[$"{sectionName}:Scope"])) + options.Scope = configuration[$"{sectionName}:Scope"]!; + if (!string.IsNullOrEmpty(configuration["Scope"])) + options.Scope = configuration["Scope"]!; + options.SectionName = sectionName; + }); + + services.AddHttpClient(TeamsApiClient.TeamsHttpClientName) + .AddHttpMessageHandler(sp => + { + BotClientOptions options = sp.GetRequiredService>().Value; + return new BotAuthenticationHandler( + sp.GetRequiredService(), + sp.GetRequiredService>(), + options.Scope, + sp.GetService>()); + }); + + services.AddBotApplication(); + return services; + } + + /// + /// Configures the TeamsBotApp + /// + /// + /// + /// + /// + public static TApp UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + where TApp : TeamsBotApplication + => endpoints.UseBotApplication(routePath); + + /// + /// Configures the default TeamsBotApplication + /// + /// + /// + /// + public static TeamsBotApplication UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + => endpoints.UseBotApplication(routePath); + + /// + /// Alias for backward compat + /// + /// + /// + /// + public static TeamsBotApplication UseTeams(this IEndpointRouteBuilder endpoints,string routePath = "api/messages") + => endpoints.UseBotApplication(routePath); +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs new file mode 100644 index 00000000..b2df78ba --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Teams specific Bot Application +/// +public class TeamsBotApplication : BotApplication +{ + private readonly TeamsApiClient _teamsApiClient; + + /// + /// Gets the router for dispatching Teams activities to registered routes. + /// + internal Router Router { get; } + + /// + /// Gets the client used to interact with the Teams API service. + /// + public TeamsApiClient TeamsApiClient => _teamsApiClient; + + /// + /// + /// + /// + /// + /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. + public TeamsBotApplication( + ConversationClient conversationClient, + UserTokenClient userTokenClient, + TeamsApiClient teamsApiClient, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + BotApplicationOptions? options = null) + : base(conversationClient, userTokenClient, logger, options) + { + _teamsApiClient = teamsApiClient; + Router = new Router(logger); + OnActivity = async (activity, cancellationToken) => + { + logger.LogInformation("OnActivity invoked for activity: Id={Id}", activity.Id); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(activity); + Context defaultContext = new(this, teamsActivity); + + if (teamsActivity.Type != TeamsActivityType.Invoke) + { + await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); + } + else // invokes + { + InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken).ConfigureAwait(false); + HttpContext? httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null && invokeResponse is not null) + { + httpContext.Response.StatusCode = invokeResponse.Status; + logger.LogTrace("Sending invoke response with status {Status} and Body {Body}", invokeResponse.Status, invokeResponse.Body); + await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); + + } + } + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs new file mode 100644 index 00000000..86ef65d3 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatActivity.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Handlers; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Extension methods for converting between Bot Framework Activity and CoreActivity/TeamsActivity. +/// +public static class CompatActivity +{ + /// + /// Converts a CoreActivity to a Bot Framework Activity. + /// + /// + /// + public static Activity ToCompatActivity(this CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + using JsonTextReader reader = new(new StringReader(activity.ToJson())); + return BotMessageHandlerBase.BotMessageSerializer.Deserialize(reader)!; + } + + /// + /// Converts a Bot Framework Activity to a TeamsActivity. + /// + /// + /// + public static CoreActivity FromCompatActivity(this Activity activity) + { + StringBuilder sb = new(); + using StringWriter stringWriter = new(sb); + using JsonTextWriter json = new(stringWriter); + BotMessageHandlerBase.BotMessageSerializer.Serialize(json, activity); + string jsonString = sb.ToString(); + return CoreActivity.FromJsonString(jsonString); + } + + + /// + /// Converts a ConversationAccount to a ChannelAccount. + /// + /// + /// + public static Microsoft.Bot.Schema.ChannelAccount ToCompatChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Bot.Schema.ChannelAccount channelAccount; + if (account is TeamsConversationAccount tae) + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name, + AadObjectId = tae.AadObjectId + }; + } + else + { + channelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + } + + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + channelAccount.AadObjectId = aadObjectId?.ToString(); + } + + if (account.Properties.TryGetValue("userRole", out object? userRole)) + { + channelAccount.Role = userRole?.ToString(); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + channelAccount.Properties.Add("userPrincipalName", userPrincipalName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + channelAccount.Properties.Add("givenName", givenName?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + channelAccount.Properties.Add("surname", surname?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + channelAccount.Properties.Add("email", email?.ToString() ?? string.Empty); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + channelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); + } + + return channelAccount; + } + + /// + /// Converts a TeamsConversationAccount to a TeamsChannelAccount. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Apps.Schema.TeamsConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + return new Microsoft.Bot.Schema.Teams.TeamsChannelAccount + { + Id = account.Id, + Name = account.Name, + AadObjectId = account.AadObjectId, + Email = account.Email, + GivenName = account.GivenName, + Surname = account.Surname, + UserPrincipalName = account.UserPrincipalName, + UserRole = account.UserRole, + TenantId = account.TenantId + }; + } + + /// + /// Converts a Core MeetingInfo to a Bot Framework MeetingInfo. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.MeetingInfo ToCompatMeetingInfo(this Microsoft.Teams.Bot.Apps.MeetingInfo meetingInfo) + { + ArgumentNullException.ThrowIfNull(meetingInfo); + + return new Microsoft.Bot.Schema.Teams.MeetingInfo + { + Details = meetingInfo.Details != null ? new Microsoft.Bot.Schema.Teams.MeetingDetails + { + Id = meetingInfo.Details.Id, + MsGraphResourceId = meetingInfo.Details.MsGraphResourceId, + ScheduledStartTime = meetingInfo.Details.ScheduledStartTime?.DateTime, + ScheduledEndTime = meetingInfo.Details.ScheduledEndTime?.DateTime, + JoinUrl = meetingInfo.Details.JoinUrl, + Title = meetingInfo.Details.Title, + Type = meetingInfo.Details.Type + } : null, + Conversation = meetingInfo.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount + { + Id = meetingInfo.Conversation.Id, + Name = meetingInfo.Conversation.Name + } : null, + Organizer = meetingInfo.Organizer != null ? meetingInfo.Organizer.ToCompatTeamsChannelAccount() : null + }; + } + + /// + /// Converts a Core MeetingParticipant to a Bot Framework TeamsMeetingParticipant. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant ToCompatTeamsMeetingParticipant(this Microsoft.Teams.Bot.Apps.MeetingParticipant participant) + { + ArgumentNullException.ThrowIfNull(participant); + + return new Microsoft.Bot.Schema.Teams.TeamsMeetingParticipant + { + User = participant.User != null ? participant.User.ToCompatTeamsChannelAccount() : null, + Meeting = participant.Meeting != null ? new Microsoft.Bot.Schema.Teams.MeetingParticipantInfo + { + Role = participant.Meeting.Role, + InMeeting = participant.Meeting.InMeeting + } : null, + Conversation = participant.Conversation != null ? new Microsoft.Bot.Schema.ConversationAccount + { + Id = participant.Conversation.Id + } : null + }; + } + + /// + /// Converts a Core TeamsChannel to a Bot Framework ChannelInfo. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.ChannelInfo ToCompatChannelInfo(this Microsoft.Teams.Bot.Apps.Schema.TeamsChannel channel) + { + ArgumentNullException.ThrowIfNull(channel); + + return new Microsoft.Bot.Schema.Teams.ChannelInfo + { + Id = channel.Id, + Name = channel.Name + }; + } + + /// + /// Converts a Core PagedMembersResult to a Bot Framework TeamsPagedMembersResult. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult ToCompatTeamsPagedMembersResult(this Microsoft.Teams.Bot.Core.PagedMembersResult pagedMembers) + { + ArgumentNullException.ThrowIfNull(pagedMembers); + + return new Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult + { + ContinuationToken = pagedMembers.ContinuationToken, + Members = pagedMembers.Members?.Select(m => m.ToCompatTeamsChannelAccount()).ToList() + }; + } + + /// + /// Converts a ConversationAccount to a TeamsChannelAccount. + /// + /// + /// + public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Bot.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + TeamsChannelAccount teamsChannelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + + // Extract properties from Properties dictionary + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + teamsChannelAccount.AadObjectId = aadObjectId?.ToString(); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + teamsChannelAccount.UserPrincipalName = userPrincipalName?.ToString(); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + teamsChannelAccount.GivenName = givenName?.ToString(); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + teamsChannelAccount.Surname = surname?.ToString(); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + teamsChannelAccount.Email = email?.ToString(); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + teamsChannelAccount.Properties.Add("tenantId", tenantId?.ToString() ?? string.Empty); + } + + return teamsChannelAccount; + } + + /// + /// Gets the TeamInfo object from the current activity. + /// + /// The activity. + /// The current activity's team's information, or null. + public static TeamInfo? TeamsGetTeamInfo(this IActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + Microsoft.Bot.Schema.Teams.TeamsChannelData channelData = activity.GetChannelData(); + return channelData?.Team; + } + + +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs new file mode 100644 index 00000000..4f586dcb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatAdapter.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides a compatibility adapter for processing bot activities and HTTP requests using legacy middleware and bot +/// framework interfaces. +/// +/// Use this adapter to bridge between legacy bot framework middleware and newer bot application models. +/// The adapter allows registration of middleware and error handling delegates, and supports processing HTTP requests +/// and continuing conversations. Thread safety is not guaranteed; instances should not be shared across concurrent +/// requests. +public class CompatAdapter : CompatBotAdapter, IBotFrameworkHttpAdapter +{ + private readonly TeamsBotApplication _teamsBotApplication; + + /// + /// Creates a new instance of the class. + /// + /// The Teams bot application instance. + /// The HTTP context accessor. + /// The logger instance. + public CompatAdapter( + TeamsBotApplication teamsBotApplication, + IHttpContextAccessor? httpContextAccessor = null, + ILogger? logger = null) + : base(teamsBotApplication, httpContextAccessor, logger) + { + _teamsBotApplication = teamsBotApplication; + } + + /// + /// Processes an incoming HTTP request and generates an appropriate HTTP response using the provided bot instance. + /// + /// The incoming HTTP request containing the bot activity. Cannot be null. + /// The HTTP response to write results to. Cannot be null. + /// The bot instance that will process the activity. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous processing operation. + public async Task ProcessAsync(HttpRequest httpRequest, HttpResponse httpResponse, IBot bot, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpRequest); + ArgumentNullException.ThrowIfNull(httpResponse); + ArgumentNullException.ThrowIfNull(bot); + + CoreActivity? coreActivity = null; + _teamsBotApplication.OnActivity = async (activity, ct) => + { + coreActivity = activity; + TurnContext turnContext = new(this, activity.ToCompatActivity()); + turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); + CompatConnectorClient connectionClient = new(new CompatConversations(_teamsBotApplication.ConversationClient) { ServiceUrl = activity.ServiceUrl?.ToString() }); + turnContext.TurnState.Add(connectionClient); + turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + await MiddlewareSet.ReceiveActivityWithStatusAsync(turnContext, bot.OnTurnAsync, ct).ConfigureAwait(false); + }; + + try + { + await _teamsBotApplication.ProcessAsync(httpRequest.HttpContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (OnTurnError != null) + { + if (ex is BotHandlerException aex) + { + coreActivity = aex.Activity; + using TurnContext turnContext = new(this, coreActivity!.ToCompatActivity()); + await OnTurnError(turnContext, ex).ConfigureAwait(false); + } + else + { + throw; + } + } + else + { + throw; + } + } + } + + /// + /// Continues an existing bot conversation by invoking the specified callback with the provided conversation + /// reference. + /// + /// Use this method to resume a conversation at a specific point, such as in response to an event + /// or proactive message. The callback is executed within the context of the continued conversation. + /// The unique identifier of the bot participating in the conversation. + /// A reference to the conversation to continue. Must not be null. + /// A delegate that handles the bot logic for the continued conversation. The callback receives a turn context and + /// cancellation token. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + public async override Task ContinueConversationAsync(string botId, ConversationReference reference, BotCallbackHandler callback, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(callback); + + using TurnContext turnContext = new(this, reference.GetContinuationActivity()); + turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); + turnContext.TurnState.Add(new CompatConnectorClient(new CompatConversations(_teamsBotApplication.ConversationClient) { ServiceUrl = reference.ServiceUrl })); + turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + await RunPipelineAsync(turnContext, callback, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs new file mode 100644 index 00000000..14260927 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatBotAdapter.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Core; +using Newtonsoft.Json; + + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides a Bot Framework adapter that enables compatibility between the Bot Framework SDK and a custom bot +/// application implementation. +/// +/// Use this adapter to bridge Bot Framework turn contexts and activities with a custom bot application. +/// This class is intended for scenarios where integration with non-standard bot runtimes or legacy systems is +/// required. +/// The Teams bot application instance. +/// The HTTP context accessor. +/// The logger instance. +public class CompatBotAdapter( + TeamsBotApplication botApplication, + IHttpContextAccessor? httpContextAccessor = null, + ILogger? logger = null) : BotAdapter +{ + private readonly JsonSerializerOptions _writeIndentedJsonOptions = new() { WriteIndented = true }; + private readonly TeamsBotApplication botApplication = botApplication; + private readonly IHttpContextAccessor? httpContextAccessor = httpContextAccessor; + private readonly ILogger? logger = logger; + + /// + /// Deletes an activity from the conversation. + /// + /// The turn context containing the activity information. Cannot be null. + /// The conversation reference identifying the activity to delete. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous delete operation. + public override async Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(turnContext); + await botApplication.ConversationClient.DeleteActivityAsync(turnContext.Activity.FromCompatActivity(), cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a set of activities to the conversation. + /// + /// The turn context for the conversation. Cannot be null. + /// An array of activities to send. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an array of + /// objects with the IDs of the sent activities. + /// + public override async Task SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activities); + ArgumentNullException.ThrowIfNull(turnContext); + + ResourceResponse[] responses = new Microsoft.Bot.Schema.ResourceResponse[activities.Length]; + + for (int i = 0; i < activities.Length; i++) + { + Activity activity = activities[i]; + + if (activity.Type == ActivityTypes.Trace) + { + return [new ResourceResponse() { Id = null }]; + } + + if (activity.Type == "invokeResponse") + { + WriteInvokeResponseToHttpResponse(activity.Value as InvokeResponse); + return [new ResourceResponse() { Id = null }]; + } + + SendActivityResponse? resp = await botApplication.SendActivityAsync(activity.FromCompatActivity(), cancellationToken).ConfigureAwait(false); + + logger?.LogInformation("Resp from SendActivitiesAsync: {RespId}", resp?.Id); + + responses[i] = new Microsoft.Bot.Schema.ResourceResponse() { Id = resp?.Id }; + } + return responses; + } + + /// + /// Updates an existing activity in the conversation. + /// + /// The turn context for the conversation. + /// The activity with updated content. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the ID of the updated activity. + /// + public override async Task UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activity); + UpdateActivityResponse res = await botApplication.ConversationClient.UpdateActivityAsync( + activity.Conversation.Id, + activity.Id, + activity.FromCompatActivity(), + cancellationToken: cancellationToken).ConfigureAwait(false); + return new ResourceResponse() { Id = res.Id }; + } + + private void WriteInvokeResponseToHttpResponse(InvokeResponse? invokeResponse) + { + ArgumentNullException.ThrowIfNull(invokeResponse); + HttpResponse? response = httpContextAccessor?.HttpContext?.Response; + if (response is not null && !response.HasStarted) + { + response.StatusCode = invokeResponse.Status; + using StreamWriter httpResponseStreamWriter = new(response.BodyWriter.AsStream()); + using JsonTextWriter httpResponseJsonWriter = new(httpResponseStreamWriter); + logger?.LogTrace("Sending Invoke Response: \n {InvokeResponse} with status: {Status} \n", System.Text.Json.JsonSerializer.Serialize(invokeResponse.Body, _writeIndentedJsonOptions), invokeResponse.Status); + if (invokeResponse.Body is not null) + { + Microsoft.Bot.Builder.Integration.AspNet.Core.HttpHelper.BotMessageSerializer.Serialize(httpResponseJsonWriter, invokeResponse.Body); + } + } + else + { + logger?.LogWarning("HTTP response is null or has started. Cannot write invoke response. ResponseStarted: {ResponseStarted}", response?.HasStarted); + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs new file mode 100644 index 00000000..2fe50811 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatConnectorClient.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Rest; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Bot.Compat +{ + /// + /// Provides a stub implementation of for compatibility with Bot Framework SDK. + /// + /// + /// This class serves as a minimal adapter to satisfy Bot Framework's requirement for an IConnectorClient instance. + /// Only the property is implemented; all other members throw . + /// This design allows legacy bots to access conversation operations through the CompatConversations adapter without + /// requiring full implementation of unused connector client features. + /// + /// The conversations adapter that handles conversation-related operations. + internal sealed class CompatConnectorClient(CompatConversations conversations) : IConnectorClient + { + /// + /// Gets the conversations interface for managing bot conversations. + /// + public IConversations Conversations => conversations; + + public Uri BaseUri { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public JsonSerializerSettings SerializationSettings => throw new NotImplementedException(); + + public JsonSerializerSettings DeserializationSettings => throw new NotImplementedException(); + + public ServiceClientCredentials Credentials => throw new NotImplementedException(); + + public IAttachments Attachments => throw new NotImplementedException(); + + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + GC.SuppressFinalize(this); + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs new file mode 100644 index 00000000..d1562500 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatConversations.cs @@ -0,0 +1,423 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Rest; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +// TODO: Figure out what to do with Agentic Identities. They're all "nulls" here right now. +// The identity is dependent on the incoming payload or supplied in for proactive scenarios. +namespace Microsoft.Teams.Bot.Compat +{ + /// + /// Provides a compatibility adapter that bridges the Teams Bot Core to the + /// Bot Framework's interface. + /// + /// + /// This adapter enables legacy Bot Framework bots to use the new Teams Bot Core conversation management + /// without code changes. It converts between Bot Framework and Core activity formats, handles HTTP operation + /// responses, and manages custom header translations. All operations delegate to the underlying Core ConversationClient. + /// + /// The underlying Teams Bot Core ConversationClient that performs the actual conversation operations. + internal sealed class CompatConversations(ConversationClient client) : IConversations + { + internal readonly ConversationClient _client = client; + + /// + /// Gets or sets the service URL for the bot service endpoint. + /// This URL is used for all conversation operations and must be set before making API calls. + /// + internal string? ServiceUrl { get; set; } + + /// + /// Creates a new conversation with the specified parameters. + /// + /// The conversation parameters including members and activity. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the conversation ID, activity ID, and service URL. + /// + /// Thrown when is null or whitespace. + public async Task> CreateConversationWithHttpMessagesAsync( + Microsoft.Bot.Schema.ConversationParameters parameters, + Dictionary>? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Microsoft.Teams.Bot.Core.ConversationParameters convoParams = new() + { + Activity = parameters.Activity.FromCompatActivity() + }; + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CreateConversationResponse res = await _client.CreateConversationAsync( + convoParams, + new Uri(ServiceUrl), + AgenticIdentity.FromProperties(convoParams.Activity?.From?.Properties), + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ConversationResourceResponse response = new() + { + ActivityId = res.ActivityId, + Id = res.Id, + ServiceUrl = res.ServiceUrl?.ToString(), + }; + + return new HttpOperationResponse + { + Body = response, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + /// + /// Deletes an existing activity from a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The unique identifier of the activity to delete. Cannot be null or whitespace. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response. + /// Thrown when is null or whitespace. + public async Task DeleteActivityWithHttpMessagesAsync(string conversationId, string activityId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + await _client.DeleteActivityAsync( + conversationId, + activityId, + new Uri(ServiceUrl), + null!, + ConvertHeaders(customHeaders), + cancellationToken).ConfigureAwait(false); + return new HttpOperationResponse + { + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task DeleteConversationMemberWithHttpMessagesAsync(string conversationId, string memberId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + await _client.DeleteConversationMemberAsync( + conversationId, + memberId, + new Uri(ServiceUrl), + null!, + ConvertHeaders(customHeaders), + cancellationToken).ConfigureAwait(false); + return new HttpOperationResponse { Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) }; + } + + public async Task>> GetActivityMembersWithHttpMessagesAsync(string conversationId, string activityId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + IList members = await _client.GetActivityMembersAsync( + conversationId, + activityId, + new Uri(ServiceUrl!), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + List channelAccounts = [.. members.Select(m => m.ToCompatChannelAccount())]; + + return new HttpOperationResponse> + { + Body = channelAccounts, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + /// + /// Retrieves the list of members participating in a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a list of objects representing the conversation members. + /// + /// Thrown when is null or whitespace. + public async Task>> GetConversationMembersWithHttpMessagesAsync(string conversationId, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + IList members = await _client.GetConversationMembersAsync( + conversationId, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + List channelAccounts = [.. members.Select(m => m.ToCompatChannelAccount())]; + + return new HttpOperationResponse> + { + Body = channelAccounts, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> GetConversationPagedMembersWithHttpMessagesAsync(string conversationId, int? pageSize = null, string? continuationToken = null, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.PagedMembersResult pagedMembers = await _client.GetConversationPagedMembersAsync( + conversationId, + new Uri(ServiceUrl), + pageSize, + continuationToken, + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + Microsoft.Bot.Schema.PagedMembersResult result = new() + { + ContinuationToken = pagedMembers.ContinuationToken, + Members = pagedMembers.Members?.Select(m => m.ToCompatChannelAccount()).ToList() + }; + + return new HttpOperationResponse + { + Body = result, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> GetConversationsWithHttpMessagesAsync(string? continuationToken = null, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + GetConversationsResponse conversations = await _client.GetConversationsAsync( + new Uri(ServiceUrl!), + continuationToken, + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ConversationsResult result = new() + { + ContinuationToken = conversations.ContinuationToken, + Conversations = conversations.Conversations?.Select(c => new Microsoft.Bot.Schema.ConversationMembers + { + Id = c.Id, + Members = c.Members?.Select(m => m.ToCompatChannelAccount()).ToList() + }).ToList() + }; + + return new HttpOperationResponse + { + Body = result, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> ReplyToActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + // ReplyToActivity is not available in ConversationClient, use SendActivityAsync with replyToId in Properties + coreActivity.Properties["replyToId"] = activityId; + if (coreActivity.Conversation == null) + { + coreActivity.Conversation = new Microsoft.Teams.Bot.Core.Schema.Conversation { Id = conversationId }; + } + else + { + coreActivity.Conversation.Id = conversationId; + } + + SendActivityResponse response = await _client.SendActivityAsync(coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> SendConversationHistoryWithHttpMessagesAsync(string conversationId, Microsoft.Bot.Schema.Transcript transcript, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.Transcript coreTranscript = new() + { + Activities = transcript.Activities?.Select(a => a.FromCompatActivity()).ToList() + }; + + SendConversationHistoryResponse response = await _client.SendConversationHistoryAsync( + conversationId, + coreTranscript, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + /// + /// Sends an activity to an existing conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The activity to send. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the ID of the sent activity. + /// + public async Task> SendToConversationWithHttpMessagesAsync(string conversationId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + // Ensure conversation ID is set + coreActivity.Conversation ??= new Microsoft.Teams.Bot.Core.Schema.Conversation { Id = conversationId }; + + SendActivityResponse response = await _client.SendActivityAsync(coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + /// + /// Updates an existing activity in a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The unique identifier of the activity to update. Cannot be null or whitespace. + /// The updated activity content. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the ID of the updated activity. + /// + public async Task> UpdateActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromCompatActivity(); + + UpdateActivityResponse response = await _client.UpdateActivityAsync(conversationId, activityId, coreActivity, convertedHeaders, cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + public async Task> UploadAttachmentWithHttpMessagesAsync(string conversationId, Microsoft.Bot.Schema.AttachmentData attachmentUpload, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Core.AttachmentData coreAttachmentData = new() + { + Type = attachmentUpload.Type, + Name = attachmentUpload.Name, + OriginalBase64 = attachmentUpload.OriginalBase64, + ThumbnailBase64 = attachmentUpload.ThumbnailBase64 + }; + + UploadAttachmentResponse response = await _client.UploadAttachmentAsync( + conversationId, + coreAttachmentData, + new Uri(ServiceUrl), + null, + convertedHeaders, + cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response.Id + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + private static Dictionary? ConvertHeaders(Dictionary>? customHeaders) + { + if (customHeaders == null) + { + return null; + } + + Dictionary convertedHeaders = []; + foreach (KeyValuePair> kvp in customHeaders) + { + convertedHeaders[kvp.Key] = string.Join(",", kvp.Value); + } + + return convertedHeaders; + } + + public async Task> GetConversationMemberWithHttpMessagesAsync(string userId, string conversationId, Dictionary> customHeaders = null!, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + Microsoft.Teams.Bot.Apps.Schema.TeamsConversationAccount response = await _client.GetConversationMemberAsync( + conversationId, userId, new Uri(ServiceUrl), null!, convertedHeaders, cancellationToken).ConfigureAwait(false); + + return new HttpOperationResponse + { + Body = response.ToCompatChannelAccount(), + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + + } + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs new file mode 100644 index 00000000..9762f5c6 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatHostingExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Bot.Apps; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides extension methods for registering compatibility adapters and related services to support legacy bot hosting +/// scenarios. +/// +/// These extension methods simplify the integration of compatibility adapters into modern hosting +/// environments by adding required services to the dependency injection container. Use these methods to enable legacy +/// bot functionality within applications built on the current hosting model. +public static class CompatHostingExtensions +{ + /// + /// Adds compatibility adapter services to the application's dependency injection container. + /// + /// This method registers services required for compatibility scenarios. It can be called + /// multiple times without adverse effects. + /// The host application builder to which the compatibility adapter services will be added. Cannot be null. + /// The same instance, enabling method chaining. + public static IHostApplicationBuilder AddCompatAdapter(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddCompatAdapter(); + return builder; + } + + /// + /// Registers the compatibility bot adapter and related services required for Bot Framework HTTP integration with + /// the application's dependency injection container. + /// + /// Call this method during application startup to enable Bot Framework HTTP endpoint support + /// using the compatibility adapter. This method should be invoked before building the service provider. + /// The service collection to which the compatibility adapter and related services will be added. Must not be null. + /// The same instance provided in , with the + /// compatibility adapter and related services registered. + public static IServiceCollection AddCompatAdapter(this IServiceCollection services) + { + services.AddTeamsBotApplication(); + services.AddSingleton(); + return services; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs new file mode 100644 index 00000000..f2bb3a9a --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.Models.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; + +namespace Microsoft.Teams.Bot.Compat; + +internal static class CompatTeamsInfoModels +{ + /// + /// Gets the TeamsMeetingInfo object from the current activity. + /// + /// The activity. + /// The current activity's meeting information, or null. + public static TeamsMeetingInfo? TeamsGetMeetingInfo(this IActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + TeamsChannelData channelData = activity.GetChannelData(); + return channelData?.Meeting; + } + + /// + /// Converts a Core BatchOperationState to a Bot Framework BatchOperationState. + /// + /// The source state. + /// The converted Bot Framework BatchOperationState. + public static Microsoft.Bot.Schema.Teams.BatchOperationState ToCompatBatchOperationState(this Microsoft.Teams.Bot.Apps.BatchOperationState state) + { + ArgumentNullException.ThrowIfNull(state); + + BatchOperationState result = new() + { + State = state.State, + RetryAfter = state.RetryAfter?.DateTime, + TotalEntriesCount = state.TotalEntriesCount ?? 0 + }; + + // StatusMap in Bot Framework SDK is IDictionary (read-only property) + // Map from BatchOperationStatusMap to the dictionary format + if (state.StatusMap != null) + { + if (state.StatusMap.Success.HasValue) + { + result.StatusMap[0] = state.StatusMap.Success.Value; + } + + if (state.StatusMap.Failed.HasValue) + { + result.StatusMap[1] = state.StatusMap.Failed.Value; + } + + if (state.StatusMap.Throttled.HasValue) + { + result.StatusMap[2] = state.StatusMap.Throttled.Value; + } + + if (state.StatusMap.Pending.HasValue) + { + result.StatusMap[3] = state.StatusMap.Pending.Value; + } + } + + return result; + } + + /// + /// Converts a Core BatchFailedEntriesResponse to a Bot Framework BatchFailedEntriesResponse. + /// + /// The source response. + /// The converted Bot Framework BatchFailedEntriesResponse. + public static Microsoft.Bot.Schema.Teams.BatchFailedEntriesResponse ToCompatBatchFailedEntriesResponse(this Microsoft.Teams.Bot.Apps.BatchFailedEntriesResponse response) + { + ArgumentNullException.ThrowIfNull(response); + + BatchFailedEntriesResponse result = new() + { + ContinuationToken = response.ContinuationToken + }; + + // FailedEntries is a read-only property with private setter, populate via the collection + if (response.FailedEntries != null) + { + foreach (Apps.BatchFailedEntry entry in response.FailedEntries) + { + result.FailedEntries.Add(entry.ToCompatBatchFailedEntry()); + } + } + + return result; + } + + /// + /// Converts a Core BatchFailedEntry to a Bot Framework BatchFailedEntry. + /// + /// The source entry. + /// The converted Bot Framework BatchFailedEntry. + public static Microsoft.Bot.Schema.Teams.BatchFailedEntry ToCompatBatchFailedEntry(this Microsoft.Teams.Bot.Apps.BatchFailedEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return new Microsoft.Bot.Schema.Teams.BatchFailedEntry + { + EntryId = entry.Id, + Error = entry.Error + }; + } + + /// + /// Converts a Core TeamDetails to a Bot Framework TeamDetails. + /// + /// The source team details. + /// The converted Bot Framework TeamDetails. + public static Microsoft.Bot.Schema.Teams.TeamDetails ToCompatTeamDetails(this Microsoft.Teams.Bot.Apps.TeamDetails teamDetails) + { + ArgumentNullException.ThrowIfNull(teamDetails); + + return new Microsoft.Bot.Schema.Teams.TeamDetails + { + Id = teamDetails.Id, + Name = teamDetails.Name, + AadGroupId = teamDetails.AadGroupId, + ChannelCount = teamDetails.ChannelCount ?? 0, + MemberCount = teamDetails.MemberCount ?? 0, + Type = teamDetails.Type + }; + } + + /// + /// Converts a Core MeetingNotificationResponse to a Bot Framework MeetingNotificationResponse. + /// + /// The source response. + /// The converted Bot Framework MeetingNotificationResponse. + public static Microsoft.Bot.Schema.Teams.MeetingNotificationResponse ToCompatMeetingNotificationResponse(this Microsoft.Teams.Bot.Apps.MeetingNotificationResponse response) + { + ArgumentNullException.ThrowIfNull(response); + + return new Microsoft.Bot.Schema.Teams.MeetingNotificationResponse + { + RecipientsFailureInfo = response.RecipientsFailureInfo?.Select(r => r.ToCompatMeetingNotificationRecipientFailureInfo()).ToList() + }; + } + + /// + /// Converts a Core MeetingNotificationRecipientFailureInfo to a Bot Framework MeetingNotificationRecipientFailureInfo. + /// + /// The source failure info. + /// The converted Bot Framework MeetingNotificationRecipientFailureInfo. + public static Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo ToCompatMeetingNotificationRecipientFailureInfo(this Microsoft.Teams.Bot.Apps.MeetingNotificationRecipientFailureInfo info) + { + ArgumentNullException.ThrowIfNull(info); + + return new Microsoft.Bot.Schema.Teams.MeetingNotificationRecipientFailureInfo + { + RecipientMri = info.RecipientMri, + ErrorCode = info.ErrorCode, + FailureReason = info.FailureReason + }; + } + + /// + /// Converts a Bot Framework TeamMember to a Core TeamMember. + /// + /// The source team member. + /// The converted Core TeamMember. + public static Microsoft.Teams.Bot.Apps.TeamMember FromCompatTeamMember(this Microsoft.Bot.Schema.Teams.TeamMember teamMember) + { + ArgumentNullException.ThrowIfNull(teamMember); + + return new Microsoft.Teams.Bot.Apps.TeamMember(teamMember.Id); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs new file mode 100644 index 00000000..2fe06299 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsInfo.cs @@ -0,0 +1,682 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; +using AppsTeams = Microsoft.Teams.Bot.Apps; +using BotFrameworkTeams = Microsoft.Bot.Schema.Teams; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides utility methods for the events and interactions that occur within Microsoft Teams. +/// This class adapts the Teams Bot Core SDK to the Bot Framework v4 SDK TeamsInfo API. +/// +public static class CompatTeamsInfo +{ + #region Helper Methods + + private static readonly System.Text.Json.JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static ConversationClient GetConversationClient(ITurnContext turnContext) + { + IConnectorClient connectorClient = turnContext.TurnState.Get() + ?? throw new InvalidOperationException("This method requires a connector client."); + + if (connectorClient is CompatConnectorClient compatClient) + { + return ((CompatConversations)compatClient.Conversations)._client; + } + + throw new InvalidOperationException("Connector client is not compatible."); + } + + private static TeamsApiClient GetTeamsApiClient(ITurnContext turnContext) + { + return turnContext.TurnState.Get() + ?? throw new InvalidOperationException("This method requires TeamsApiClient."); + } + + private static string GetServiceUrl(ITurnContext turnContext) + { + return turnContext.Activity.ServiceUrl + ?? throw new InvalidOperationException("ServiceUrl is required."); + } + + private static AgenticIdentity GetIdentity(ITurnContext turnContext) + { + CoreActivity coreActivity = turnContext.Activity.FromCompatActivity(); + return AgenticIdentity.FromProperties(coreActivity.From?.Properties) ?? new AgenticIdentity(); + } + + #endregion + + #region Member & Participant Methods + + /// + /// Gets the account of a single conversation member. + /// This works in one-on-one, group, and teams scoped conversations. + /// + /// Turn context. + /// ID of the user in question. + /// Cancellation token. + /// The member's channel account information. + public static async Task GetMemberAsync( + ITurnContext turnContext, + string userId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetTeamMemberAsync(turnContext, userId, teamInfo.Id, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMember operation needs a valid conversation Id."); + + if (userId == null) + { + throw new InvalidOperationException("The GetMember operation needs a valid user Id."); + } + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + TeamsConversationAccount result = await client.GetConversationMemberAsync( + conversationId, userId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamsChannelAccount(); + } + } + + /// + /// Gets the conversation members of a one-on-one or group chat. + /// + /// Turn context. + /// Cancellation token. + /// List of channel accounts. + [Obsolete("Microsoft Teams is deprecating the non-paged version of the getMembers API which this method uses. Please use GetPagedMembersAsync instead of this API.")] + public static async Task> GetMembersAsync( + ITurnContext turnContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetTeamMembersAsync(turnContext, teamInfo.Id, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMembers operation needs a valid conversation Id."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + IList members = await client.GetConversationMembersAsync( + conversationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return members.Select(m => m.ToCompatTeamsChannelAccount()); + } + } + + /// + /// Gets a paginated list of members of one-on-one, group, or team conversation. + /// + /// Turn context. + /// Suggested number of entries on a page. + /// Continuation token. + /// Cancellation token. + /// Paged members result. + public static async Task GetPagedMembersAsync( + ITurnContext turnContext, + int? pageSize = default, + string? continuationToken = default, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetPagedTeamMembersAsync(turnContext, teamInfo.Id, continuationToken, pageSize, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMembers operation needs a valid conversation Id."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.PagedMembersResult pagedMembers = await client.GetConversationPagedMembersAsync( + conversationId, serviceUrl, pageSize, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + return pagedMembers.ToCompatTeamsPagedMembersResult(); + } + } + + /// + /// Gets the member of a teams scoped conversation. + /// + /// Turn context. + /// User id. + /// ID of the Teams team. + /// Cancellation token. + /// Team member's channel account. + public static async Task GetTeamMemberAsync( + ITurnContext turnContext, + string userId, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + if (userId == null) + { + throw new InvalidOperationException("The GetMember operation needs a valid user Id."); + } + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + TeamsConversationAccount result = await client.GetConversationMemberAsync( + t, userId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamsChannelAccount(); + } + + /// + /// Gets the list of BotFrameworkTeams.TeamsChannelAccounts within a team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Cancellation token. + /// List of team members. + [Obsolete("Microsoft Teams is deprecating the non-paged version of the getMembers API which this method uses. Please use GetPagedTeamMembersAsync instead of this API.")] + public static async Task> GetTeamMembersAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + IList members = await client.GetConversationMembersAsync( + t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return members.Select(m => m.ToCompatTeamsChannelAccount()); + } + + /// + /// Gets a paginated list of members of a team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Continuation token. + /// Number of entries on the page. + /// Cancellation token. + /// Paged team members result. + public static async Task GetPagedTeamMembersAsync( + ITurnContext turnContext, + string? teamId = null, + string? continuationToken = default, + int? pageSize = default, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.PagedMembersResult pagedMembers = await client.GetConversationPagedMembersAsync( + t, serviceUrl, pageSize, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + return pagedMembers.ToCompatTeamsPagedMembersResult(); + } + + #endregion + + #region Meeting Methods + + /// + /// Gets the information for the given meeting id. + /// + /// Turn context. + /// The BASE64-encoded id of the Teams meeting. + /// Cancellation token. + /// Meeting information. + public static async Task GetMeetingInfoAsync( + ITurnContext turnContext, + string? meetingId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + AppsTeams.MeetingInfo result = await client.FetchMeetingInfoAsync( + meetingId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatMeetingInfo(); + } + + /// + /// Gets the details for the given meeting participant. This only works in teams meeting scoped conversations. + /// + /// Turn context. + /// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. + /// The id of the Teams meeting participant. From.AadObjectId will be used if none provided. + /// The id of the Teams meeting Tenant. BotFrameworkTeams.TeamsChannelData.Tenant.Id will be used if none provided. + /// Cancellation token. + /// Team participant channel account. + public static async Task GetMeetingParticipantAsync( + ITurnContext turnContext, + string? meetingId = null, + string? participantId = null, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + participantId ??= turnContext.Activity.From.AadObjectId + ?? throw new InvalidOperationException($"{nameof(participantId)} is required."); + tenantId ??= turnContext.Activity.GetChannelData()?.Tenant?.Id + ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + MeetingParticipant result = await client.FetchParticipantAsync( + meetingId, participantId, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamsMeetingParticipant(); + } + + /// + /// Sends a notification to meeting participants. This functionality is available only in teams meeting scoped conversations. + /// + /// Turn context. + /// The notification to send to Teams. + /// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. + /// Cancellation token. + /// Meeting notification response. + public static async Task SendMeetingNotificationAsync( + ITurnContext turnContext, + BotFrameworkTeams.MeetingNotificationBase? notification, + string? meetingId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + notification = notification ?? throw new InvalidOperationException($"{nameof(notification)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + // Convert Bot Framework MeetingNotificationBase to Core MeetingNotificationBase using JSON round-trip + string json = Newtonsoft.Json.JsonConvert.SerializeObject(notification); + AppsTeams.TargetedMeetingNotification? coreNotification = System.Text.Json.JsonSerializer.Deserialize(json, s_jsonOptions); + + + AppsTeams.MeetingNotificationResponse result = await client.SendMeetingNotificationAsync( + meetingId, coreNotification!, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatMeetingNotificationResponse(); + } + + #endregion + + #region Team & Channel Methods + + /// + /// Gets the details for the given team id. This only works in teams scoped conversations. + /// + /// Turn context. + /// The id of the Teams team. + /// Cancellation token. + /// Team details. + public static async Task GetTeamDetailsAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + AppsTeams.TeamDetails result = await client.FetchTeamDetailsAsync( + t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamDetails(); + } + + /// + /// Returns a list of channels in a Team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Cancellation token. + /// List of channel information. + public static async Task> GetTeamChannelsAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + ChannelList channelList = await client.FetchChannelListAsync( + t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return channelList.Channels?.Select(c => c.ToCompatChannelInfo()).ToList() ?? []; + } + + #endregion + + #region Batch Messaging Methods + + /// + /// Sends a message to the provided list of Teams members. + /// + /// Turn context. + /// The activity to send. + /// The list of members. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToListOfUsersAsync( + ITurnContext turnContext, + IActivity activity, + IList teamsMembers, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + teamsMembers = teamsMembers ?? throw new InvalidOperationException($"{nameof(teamsMembers)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + List coreTeamsMembers = teamsMembers.Select(m => m.FromCompatTeamMember()).ToList(); + + return await client.SendMessageToListOfUsersAsync( + coreActivity, coreTeamsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a message to the provided list of Teams channels. + /// + /// Turn context. + /// The activity to send. + /// The list of channels. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToListOfChannelsAsync( + ITurnContext turnContext, + IActivity activity, + IList channelsMembers, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + channelsMembers = channelsMembers ?? throw new InvalidOperationException($"{nameof(channelsMembers)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + List coreChannelsMembers = channelsMembers.Select(m => m.FromCompatTeamMember()).ToList(); + + return await client.SendMessageToListOfChannelsAsync( + coreActivity, coreChannelsMembers, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a message to all the users in a team. + /// + /// The turn context. + /// The activity to send to the users in the team. + /// The team ID. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToAllUsersInTeamAsync( + ITurnContext turnContext, + IActivity activity, + string teamId, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + teamId = teamId ?? throw new InvalidOperationException($"{nameof(teamId)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + return await client.SendMessageToAllUsersInTeamAsync( + coreActivity, teamId, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a message to all the users in a tenant. + /// + /// The turn context. + /// The activity to send to the tenant. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToAllUsersInTenantAsync( + ITurnContext turnContext, + IActivity activity, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + CoreActivity coreActivity = ((Activity)activity).FromCompatActivity(); + + return await client.SendMessageToAllUsersInTenantAsync( + coreActivity, tenantId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new thread in a team chat and sends an activity to that new thread. + /// Use this method if you are using CloudAdapter where credentials are handled by the adapter. + /// + /// Turn context. + /// The activity to send on starting the new thread. + /// The Team's Channel ID, note this is distinct from the Bot Framework activity property with same name. + /// The bot's appId. + /// Cancellation token. + /// Tuple with conversation reference and activity id. + public static async Task> SendMessageToTeamsChannelAsync( + ITurnContext turnContext, + IActivity activity, + string teamsChannelId, + string botAppId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + + if (turnContext.Activity == null) + { + throw new InvalidOperationException(nameof(turnContext.Activity)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(teamsChannelId); + + ConversationReference? conversationReference = null; + string newActivityId = string.Empty; + string serviceUrl = turnContext.Activity.ServiceUrl; + Microsoft.Bot.Schema.ConversationParameters conversationParameters = new() + { + IsGroup = true, + ChannelData = new BotFrameworkTeams.TeamsChannelData { Channel = new BotFrameworkTeams.ChannelInfo { Id = teamsChannelId } }, + Activity = (Activity)activity, + }; + + await turnContext.Adapter.CreateConversationAsync( + botAppId, + Channels.Msteams, + serviceUrl, + null, + conversationParameters, + (t, ct) => + { + conversationReference = t.Activity.GetConversationReference(); + newActivityId = t.Activity.Id; + return Task.CompletedTask; + }, + cancellationToken).ConfigureAwait(false); + + return new Tuple(conversationReference!, newActivityId); + } + + #endregion + + #region Batch Operation Management + + /// + /// Gets the state of an operation. + /// + /// Turn context. + /// The operationId to get the state of. + /// Cancellation token. + /// The state and responses of the operation. + public static async Task GetOperationStateAsync( + ITurnContext turnContext, + string operationId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + AppsTeams.BatchOperationState result = await client.GetOperationStateAsync( + operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatBatchOperationState(); + } + + /// + /// Gets the failed entries of a batch operation. + /// + /// The turn context. + /// The operationId to get the failed entries of. + /// The continuation token. + /// Cancellation token. + /// The list of failed entries of the operation. + public static async Task GetPagedFailedEntriesAsync( + ITurnContext turnContext, + string operationId, + string? continuationToken = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + AppsTeams.BatchFailedEntriesResponse result = await client.GetPagedFailedEntriesAsync( + operationId, serviceUrl, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatBatchFailedEntriesResponse(); + } + + /// + /// Cancels a batch operation by its id. + /// + /// The turn context. + /// The id of the operation to cancel. + /// Cancellation token. + /// A task representing the asynchronous operation. + public static async Task CancelOperationAsync( + ITurnContext turnContext, + string operationId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + TeamsApiClient client = GetTeamsApiClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + await client.CancelOperationAsync( + operationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + } + + #endregion +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs new file mode 100644 index 00000000..0dc79277 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatUserTokenClient.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// Provides a compatibility layer that adapts the Teams Bot Core to the Bot Framework's +/// interface. +/// +/// +/// This adapter enables legacy Bot Framework bots to use the new Teams Bot Core token management system +/// without code changes. It converts between the two different token result formats and delegates all operations +/// to the underlying Core UserTokenClient. +/// +/// The underlying Teams Bot Core UserTokenClient that performs the actual token operations. +internal sealed class CompatUserTokenClient(UserTokenClient utc) : Microsoft.Bot.Connector.Authentication.UserTokenClient +{ + /// + /// Gets the status of all tokens for a specific user across all configured OAuth connections. + /// + /// The unique identifier of the user. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// Optional filter to limit which token statuses are returned. Pass null or empty to include all. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an array of + /// objects representing the status of each configured connection for the user. + /// + public async override Task GetTokenStatusAsync(string userId, string channelId, string includeFilter, CancellationToken cancellationToken) + { + GetTokenStatusResult[] res = await utc.GetTokenStatusAsync(userId, channelId, includeFilter, cancellationToken).ConfigureAwait(false); + return res.Select(t => new TokenStatus + { + ChannelId = channelId, + ConnectionName = t.ConnectionName, + HasToken = t.HasToken, + ServiceProviderDisplayName = t.ServiceProviderDisplayName, + }).ToArray(); + } + + /// + /// Retrieves an OAuth token for a user from a specific connection. + /// + /// The unique identifier of the user requesting the token. Cannot be null or empty. + /// The name of the OAuth connection configured in Azure Bot Service. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// Optional magic code from the OAuth callback. Used to complete the OAuth flow when provided. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a with + /// the OAuth token if available, or null if the user has not completed authentication for this connection. + /// + public async override Task GetUserTokenAsync(string userId, string connectionName, string channelId, string magicCode, CancellationToken cancellationToken) + { + GetTokenResult? res = await utc.GetTokenAsync(userId, connectionName, channelId, magicCode, cancellationToken).ConfigureAwait(false); + if (res == null) + { + return null; + } + + return new TokenResponse + { + ChannelId = channelId, + ConnectionName = res.ConnectionName, + Token = res.Token + }; + } + + /// + /// Retrieves the sign-in resource (URL and exchange resources) needed to initiate an OAuth flow for a user. + /// + /// The name of the OAuth connection configured in Azure Bot Service. Cannot be null or empty. + /// The activity associated with the sign-in request. Used to extract user and channel information. Cannot be null. + /// Optional URL to redirect the user to after completing authentication. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the sign-in link and optional token exchange or post resources for completing the OAuth flow. + /// + /// Thrown when is null. + public async override Task GetSignInResourceAsync(string connectionName, Activity activity, string finalRedirect, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activity); + GetSignInResourceResult res = await utc.GetSignInResource(activity.From.Id, connectionName, activity.ChannelId, finalRedirect, cancellationToken).ConfigureAwait(false); + SignInResource signInResource = new() + { + SignInLink = res!.SignInLink + }; + + if (res.TokenExchangeResource != null) + { + signInResource.TokenExchangeResource = new Microsoft.Bot.Schema.TokenExchangeResource + { + Id = res.TokenExchangeResource.Id, + Uri = res.TokenExchangeResource.Uri?.ToString(), + ProviderId = res.TokenExchangeResource.ProviderId + }; + } + + if (res.TokenPostResource != null) + { + signInResource.TokenPostResource = new Microsoft.Bot.Schema.TokenPostResource + { + SasUrl = res.TokenPostResource.SasUrl?.ToString() + }; + } + + return signInResource; + } + + /// + /// Exchanges a token from one OAuth connection for a token from another connection using single sign-on (SSO). + /// + /// The unique identifier of the user whose token is being exchanged. Cannot be null or empty. + /// The name of the target OAuth connection to exchange to. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// The token exchange request containing the source token. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the exchanged token for the target connection. + /// + public async override Task ExchangeTokenAsync(string userId, string connectionName, string channelId, + TokenExchangeRequest exchangeRequest, CancellationToken cancellationToken) + { + GetTokenResult resp = await utc.ExchangeTokenAsync(userId, connectionName, channelId, exchangeRequest.Token, + cancellationToken).ConfigureAwait(false); + return new TokenResponse + { + ChannelId = channelId, + ConnectionName = resp.ConnectionName, + Token = resp.Token + }; + } + + /// + /// Signs out a user from a specific OAuth connection, revoking their stored token. + /// + /// The unique identifier of the user to sign out. Cannot be null or empty. + /// The name of the OAuth connection to sign out from. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous sign-out operation. + public async override Task SignOutUserAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken) + { + await utc.SignOutUserAsync(userId, connectionName, channelId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves Azure Active Directory (Azure AD) tokens for multiple resource URLs in a single request. + /// + /// The unique identifier of the user requesting the tokens. Cannot be null or empty. + /// The name of the OAuth connection configured for Azure AD. Cannot be null or empty. + /// An array of resource URLs (e.g., "https://graph.microsoft.com") to request tokens for. Cannot be null. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each + /// resource URL to its corresponding . Returns an empty dictionary if no tokens are available. + /// + public async override Task> GetAadTokensAsync(string userId, string connectionName, string[] resourceUrls, string channelId, CancellationToken cancellationToken) + { + IDictionary res = await utc.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls, cancellationToken).ConfigureAwait(false); + return res?.ToDictionary(kvp => kvp.Key, kvp => new TokenResponse + { + ChannelId = channelId, + ConnectionName = kvp.Value.ConnectionName, + Token = kvp.Value.Token + }) ?? new Dictionary(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Bot.Compat/GlobalSuppressions.cs new file mode 100644 index 00000000..9bfa5cf9 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/GlobalSuppressions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", + "CA1873:Avoid potentially expensive logging", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Compat")] + +[assembly: SuppressMessage("Performance", + "CA1848:Use the LoggerMessage delegates", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Compat")] diff --git a/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs new file mode 100644 index 00000000..c1d99fd1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/InternalsVisibleTo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Teams.Bot.Core.Tests")] diff --git a/core/src/Microsoft.Teams.Bot.Compat/KeyedBotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Bot.Compat/KeyedBotAuthenticationHandler.cs new file mode 100644 index 00000000..31ef4aa5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/KeyedBotAuthenticationHandler.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// A delegating handler that adds authentication headers to outgoing HTTP requests +/// using named MSAL configuration options. +/// +/// +/// +/// This handler acquires OAuth tokens using the Microsoft Identity platform and adds +/// them to outgoing requests. It supports both app-only tokens and agentic (user-delegated) +/// tokens when an is present in the request options. +/// +/// +/// The handler uses named to support +/// multi-instance scenarios where different bot configurations require different credentials. +/// +/// +internal sealed class KeyedBotAuthenticationHandler : DelegatingHandler +{ + private readonly string _msalOptionsName; + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider; + private readonly ILogger _logger; + private readonly string _scope; + private readonly IOptions? _managedIdentityOptions; + + /// + /// Key used to store the agentic identity in HttpRequestMessage options. + /// + public static readonly HttpRequestOptionsKey AgenticIdentityKey = new("AgenticIdentity"); + + /// + /// Initializes a new instance of the class. + /// + /// The name of the MSAL configuration options to use for token acquisition. + /// The provider used to create authorization headers. + /// The logger for diagnostic output. + /// The OAuth scope for token acquisition. + /// Optional managed identity configuration. + /// + /// Thrown when , , + /// or is null. + /// + public KeyedBotAuthenticationHandler( + string msalOptionsName, + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger, + string scope, + IOptions? managedIdentityOptions = null) + { + _msalOptionsName = msalOptionsName ?? throw new ArgumentNullException(nameof(msalOptionsName)); + _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _managedIdentityOptions = managedIdentityOptions; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Options.TryGetValue(AgenticIdentityKey, out AgenticIdentity? agenticIdentity); + + string token = await GetAuthorizationHeaderAsync(agenticIdentity, cancellationToken).ConfigureAwait(false); + + string tokenValue = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? token["Bearer ".Length..] + : token; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenValue); + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets an authorization header for Bot Framework API calls. + /// Supports both app-only and agentic (user-delegated) token acquisition. + /// + private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) + { + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = _msalOptionsName + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + options.AcquireTokenOptions.ManagedIdentity = miOptions; + } + } + + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logger.LogInformation( + "Acquiring agentic token for scope '{Scope}' with AppId '{AppId}' and AgentUserId '{AgentUserId}'.", + _scope, + agenticIdentity.AgenticAppId, + agenticIdentity.AgenticUserId); + + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, Guid.Parse(agenticIdentity.AgenticUserId)); + string token = await _authorizationHeaderProvider + .CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken) + .ConfigureAwait(false); + return token; + } + + _logger.LogInformation("Acquiring app-only token for scope: {Scope}", _scope); + string appToken = await _authorizationHeaderProvider + .CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken) + .ConfigureAwait(false); + return appToken; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj b/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj new file mode 100644 index 00000000..540a19fa --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj @@ -0,0 +1,15 @@ + + + + net8.0;net10.0 + enable + enable + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs new file mode 100644 index 00000000..c7e181bb --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents a bot application. +/// +public class BotApplication +{ + private readonly ILogger _logger; + private readonly ConversationClient? _conversationClient; + private readonly UserTokenClient? _userTokenClient; + internal TurnMiddleware MiddleWare { get; } + + /// + /// Creates a default instance, primarily for testing purposes. The ConversationClient and UserTokenClient properties will not be initialized + /// + protected BotApplication() + { + _logger = NullLogger.Instance; + MiddleWare = new TurnMiddleware(); + } + + /// + /// Initializes a new instance of the BotApplication class with the specified conversation client, app ID, + /// and logger. + /// Initializes a new instance of the BotApplication class with the specified conversation client, app ID, + /// and logger. + /// + /// The client used to manage and interact with conversations for the bot. + /// The client used to manage user tokens for authentication. + /// The logger used to record operational and diagnostic information for the bot application. + /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. + public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger logger, BotApplicationOptions? options = null) + { + options ??= new(); + _logger = logger; + MiddleWare = new TurnMiddleware(); + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); + } + + + /// + /// Gets the client used to manage and interact with conversations. + /// + /// Accessing this property before the client is initialized will result in an exception. Ensure + /// that the client is properly configured before use. + public ConversationClient ConversationClient => _conversationClient ?? throw new InvalidOperationException("ConversationClient not initialized"); + + /// + /// Gets the client used to manage user tokens for authentication. + /// + /// Accessing this property before the client is initialized will result in an exception. Ensure + /// that the client is properly configured before use. + public UserTokenClient UserTokenClient => _userTokenClient ?? throw new InvalidOperationException("UserTokenClient not registered"); + + /// + /// Gets or sets the delegate that is invoked to handle an incoming activity asynchronously. + /// + /// Assign a delegate to process activities as they are received. The delegate should accept an + /// and a , and return a representing the + /// asynchronous operation. If , incoming activities will not be handled. + public virtual Func? OnActivity { get; set; } + + /// + /// Processes an incoming HTTP request containing a bot activity. + /// + /// + /// + /// + /// + /// + public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(_conversationClient); + + _logger.LogDebug("Start processing HTTP request for activity"); + + CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity"); + + _logger.LogInformation("Activity received: Type={Type} Id={Id} ServiceUrl={ServiceUrl} MSCV={MSCV}", + activity.Type, + activity.Id, + activity.ServiceUrl, + httpContext.Request.GetCorrelationVector()); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Received activity: {Activity}", activity.ToJson()); + } + + // TODO: Replace with structured scope data, ensure it works with OpenTelemetry and other logging providers + using (_logger.BeginScope("ActivityType={ActivityType} ActivityId={ActivityId} ServiceUrl={ServiceUrl} MSCV={MSCV}", + activity.Type, activity.Id, activity.ServiceUrl, httpContext.Request.GetCorrelationVector())) + { + try + { + CancellationToken token = Debugger.IsAttached ? CancellationToken.None : cancellationToken; + await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing activity: Id={Id}", activity.Id); + throw new BotHandlerException("Error processing activity", ex, activity); + } + finally + { + _logger.LogInformation("Finished processing activity: Id={Id}", activity.Id); + } + } + } + + /// + /// Adds the specified turn middleware to the middleware pipeline. + /// + /// The middleware component to add to the pipeline. Cannot be null. + /// An ITurnMiddleWare instance representing the updated middleware pipeline. + public ITurnMiddleware UseMiddleware(ITurnMiddleware middleware) + { + MiddleWare.Use(middleware); + return MiddleWare; + } + + /// + /// Sends the specified activity to the conversation asynchronously. + /// + /// The activity to send to the conversation. Cannot be null. + /// A cancellation token that can be used to cancel the send operation. + /// A task that represents the asynchronous operation. The task result contains the identifier of the sent activity. + /// Thrown if the conversation client has not been initialized. + public async Task SendActivityAsync(CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(_conversationClient, "ConversationClient not initialized"); + + return await _conversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the version of the SDK. + /// + public static string Version => ThisAssembly.NuGetPackageVersion; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs b/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs new file mode 100644 index 00000000..6301925b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/BotHandlerException.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents errors that occur during bot activity processing and provides context about the associated activity. +/// +/// Use this exception to capture and propagate errors that occur during bot activity handling, along +/// with contextual information about the activity involved. This can aid in debugging and error reporting +/// scenarios. +public class BotHandlerException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public BotHandlerException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that describes the reason for the exception. + public BotHandlerException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and inner exception. + /// + /// The error message that describes the reason for the exception. + /// The underlying exception that caused this exception, or null if no inner exception is specified. + public BotHandlerException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with a specified error message, inner exception, and activity. + /// + /// The error message that describes the reason for the exception. + /// The underlying exception that caused this exception, or null if no inner exception is specified. + /// The bot activity associated with the error. Cannot be null. + public BotHandlerException(string message, Exception innerException, CoreActivity activity) : base(message, innerException) + { + Activity = activity; + } + + /// + /// Accesses the bot activity associated with the exception. + /// + public CoreActivity? Activity { get; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs new file mode 100644 index 00000000..4e794595 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.Models.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Response from sending an activity. +/// +public class SendActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from updating an activity. +/// +public class UpdateActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from deleting an activity. +/// +public class DeleteActivityResponse +{ + /// + /// Id of the activity + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Response from getting conversations. +/// +public class GetConversationsResponse +{ + /// + /// Gets or sets the continuation token that can be used to get paged results. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of conversations. + /// + [JsonPropertyName("conversations")] + public IList? Conversations { get; set; } +} + +/// +/// Represents a conversation and its members. +/// +public class ConversationMembers +{ + /// + /// Gets or sets the conversation ID. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the list of members in this conversation. + /// + [JsonPropertyName("members")] + public IList? Members { get; set; } +} + +/// +/// Parameters for creating a new conversation. +/// +public class ConversationParameters +{ + /// + /// Gets or sets a value indicating whether the conversation is a group conversation. + /// + [JsonPropertyName("isGroup")] + public bool? IsGroup { get; set; } + + /// + /// Gets or sets the bot's account for this conversation. + /// + [JsonPropertyName("bot")] + public ConversationAccount? Bot { get; set; } + + /// + /// Gets or sets the list of members to add to the conversation. + /// + [JsonPropertyName("members")] + public IList? Members { get; set; } + + /// + /// Gets or sets the topic name for the conversation (if supported by the channel). + /// + [JsonPropertyName("topicName")] + public string? TopicName { get; set; } + + /// + /// Gets or sets the initial activity to send when creating the conversation. + /// + [JsonPropertyName("activity")] + public CoreActivity? Activity { get; set; } + + /// + /// Gets or sets channel-specific payload for creating the conversation. + /// + [JsonPropertyName("channelData")] + public object? ChannelData { get; set; } + + /// + /// Gets or sets the tenant ID where the conversation should be created. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Response from creating a conversation. +/// +public class CreateConversationResponse +{ + /// + /// Gets or sets the ID of the activity (if sent). + /// + [JsonPropertyName("activityId")] + public string? ActivityId { get; set; } + + /// + /// Gets or sets the service endpoint where operations concerning the conversation may be performed. + /// + [JsonPropertyName("serviceUrl")] + public Uri? ServiceUrl { get; set; } + + /// + /// Gets or sets the identifier of the conversation resource. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Result from getting paged members of a conversation. +/// +public class PagedMembersResult +{ + /// + /// Gets or sets the continuation token that can be used to get paged results. + /// + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + /// + /// Gets or sets the list of members in this page. + /// + [JsonPropertyName("members")] + public IList? Members { get; set; } +} + +/// +/// A collection of activities that represents a conversation transcript. +/// +public class Transcript +{ + /// + /// Gets or sets the collection of activities that conforms to the Transcript schema. + /// + [JsonPropertyName("activities")] + public IList? Activities { get; set; } +} + +/// +/// Response from sending conversation history. +/// +public class SendConversationHistoryResponse +{ + /// + /// Gets or sets the ID of the resource. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} + +/// +/// Represents attachment data for uploading. +/// +public class AttachmentData +{ + /// + /// Gets or sets the Content-Type of the attachment. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the name of the attachment. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the attachment content as a byte array. + /// + [JsonPropertyName("originalBase64")] +#pragma warning disable CA1819 // Properties should not return arrays + public byte[]? OriginalBase64 { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays + + /// + /// Gets or sets the attachment thumbnail as a byte array. + /// + [JsonPropertyName("thumbnailBase64")] +#pragma warning disable CA1819 // Properties should not return arrays + public byte[]? ThumbnailBase64 { get; set; } +#pragma warning restore CA1819 // Properties should not return arrays +} + +/// +/// Response from uploading an attachment. +/// +public class UploadAttachmentResponse +{ + /// + /// Gets or sets the ID of the uploaded attachment. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs new file mode 100644 index 00000000..87b62f6e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ConversationClient.cs @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +using CustomHeaders = Dictionary; + +/// +/// Provides methods for sending activities to a conversation endpoint using HTTP requests. +/// +/// The HTTP client instance used to send requests to the conversation service. Must not be null. +/// The logger instance used for logging. Optional. +public class ConversationClient(HttpClient httpClient, ILogger logger = default!) +{ + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + internal const string ConversationHttpClientName = "BotConversationClient"; + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders DefaultCustomHeaders { get; } = []; + + /// + /// Sends the specified activity to the conversation endpoint asynchronously. + /// + /// The activity to send. Cannot be null. The activity must contain valid conversation and service URL information. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the send operation. + /// A task that represents the asynchronous operation. The task result contains the response with the ID of the sent activity. + /// Thrown if the activity could not be sent successfully. The exception message includes the HTTP status code and + /// response content. + public virtual async Task SendActivityAsync(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{activity.Conversation.Id}/activities/"; + + if (activity.ChannelId == "agents") + { + logger.LogInformation("Truncating conversation ID for 'agents' channel to comply with length restrictions."); + string conversationId = activity.Conversation.Id; + string convId = conversationId.Length > 100 ? conversationId[..100] : conversationId; + url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{convId}/activities/"; + } + + if (!string.IsNullOrEmpty(activity.ReplyToId)) + { + url += activity.ReplyToId; + } + + logger?.LogInformation("Sending activity with type `{Type}` to {Url}", activity.Type, url); + + string body = activity.ToJson(); + + logger?.LogTrace("Outgoing Activity :\r {Activity}", body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(activity.From?.GetAgenticIdentity(), "sending activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Updates an existing activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to update. Cannot be null or whitespace. + /// The updated activity data. Cannot be null. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the update operation. + /// A task that represents the asynchronous operation. The task result contains the response with the ID of the updated activity. + /// Thrown if the activity could not be updated successfully. + public virtual async Task UpdateActivityAsync(string conversationId, string activityId, CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + string body = activity.ToJson(); + + logger.LogTrace("Updating activity at {Url}: {Activity}", url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(activity.From?.GetAgenticIdentity(), "updating activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + + /// + /// Deletes an existing activity from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public virtual async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}"; + + logger.LogTrace("Deleting activity at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting activity", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an existing activity from a conversation using activity context. + /// + /// The activity to delete. Must contain valid Id, Conversation.Id, and ServiceUrl. Cannot be null. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public virtual async Task DeleteActivityAsync(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Conversation.Id); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + await DeleteActivityAsync( + activity.Conversation.Id, + activity.Id, + activity.ServiceUrl, + activity.From?.GetAgenticIdentity(), + customHeaders, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the members of a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a list of conversation members. + /// Thrown if the members could not be retrieved successfully. + public virtual async Task> GetConversationMembersAsync(string conversationId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members"; + + logger.LogTrace("Getting conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync>( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversation members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + + /// + /// Gets a specific member of a conversation with strongly-typed result. + /// + /// The type of conversation account to return. Must inherit from . + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the user to retrieve. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// + /// A task that represents the asynchronous operation. The task result contains the conversation member + /// of type T with detailed information about the user. + /// + /// Thrown if the member could not be retrieved successfully. + public virtual async Task GetConversationMemberAsync(string conversationId, string userId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) where T : ConversationAccount + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members/{userId}"; + + logger.LogTrace("Getting conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversation member", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the conversations in which the bot has participated. + /// + /// The service URL for the bot. Cannot be null. + /// Optional continuation token for pagination. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the conversations and an optional continuation token. + /// Thrown if the conversations could not be retrieved successfully. + public virtual async Task GetConversationsAsync(Uri serviceUrl, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations"; + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + logger.LogTrace("Getting conversations from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting conversations", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the members of a specific activity. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a list of members for the activity. + /// Thrown if the activity members could not be retrieved successfully. + public virtual async Task> GetActivityMembersAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/{activityId}/members"; + + logger.LogTrace("Getting activity members from {Url}", url); + + return (await _botHttpClient.SendAsync>( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting activity members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Creates a new conversation. + /// + /// The parameters for creating the conversation. Cannot be null. + /// The service URL for the bot. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the conversation resource response with the conversation ID. + /// Thrown if the conversation could not be created successfully. + public virtual async Task CreateConversationAsync(ConversationParameters parameters, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(parameters); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations"; + + logger.LogTrace("Creating conversation at {Url} with parameters: {Parameters}", url, JsonSerializer.Serialize(parameters)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(parameters), + CreateRequestOptions(agenticIdentity, "creating conversation", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the members of a conversation one page at a time. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional page size for the number of members to retrieve. + /// Optional continuation token for pagination. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a page of members and an optional continuation token. + /// Thrown if the conversation members could not be retrieved successfully. + public virtual async Task GetConversationPagedMembersAsync(string conversationId, Uri serviceUrl, int? pageSize = null, string? continuationToken = null, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/pagedmembers"; + + List queryParams = []; + if (pageSize.HasValue) + { + queryParams.Add($"pageSize={pageSize.Value}"); + } + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}"); + } + if (queryParams.Count > 0) + { + url += $"?{string.Join("&", queryParams)}"; + } + + logger.LogTrace("Getting paged conversation members from {Url}", url); + + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting paged conversation members", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Deletes a member from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the member to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the member could not be deleted successfully. + /// If the deleted member was the last member of the conversation, the conversation is also deleted. + public virtual async Task DeleteConversationMemberAsync(string conversationId, string memberId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(memberId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/members/{memberId}"; + + logger.LogTrace("Deleting conversation member at {Url}", url); + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting conversation member", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Uploads and sends historic activities to the conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The transcript containing the historic activities. Cannot be null. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the response with a resource ID. + /// Thrown if the history could not be sent successfully. + /// Activities in the transcript must have unique IDs and appropriate timestamps for proper rendering. + public virtual async Task SendConversationHistoryAsync(string conversationId, Transcript transcript, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(transcript); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/activities/history"; + + logger.LogTrace("Sending conversation history to {Url}: {Transcript}", url, JsonSerializer.Serialize(transcript)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(transcript), + CreateRequestOptions(agenticIdentity, "sending conversation history", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Uploads an attachment to the channel's blob storage. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The attachment data to upload. Cannot be null. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the response with an attachment ID. + /// Thrown if the attachment could not be uploaded successfully. + /// This is useful for storing data in a compliant store when dealing with enterprises. + public virtual async Task UploadAttachmentAsync(string conversationId, AttachmentData attachmentData, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(attachmentData); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{conversationId}/attachments"; + + logger.LogTrace("Uploading attachment to {Url}: {AttachmentData}", url, JsonSerializer.Serialize(attachmentData)); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + JsonSerializer.Serialize(attachmentData), + CreateRequestOptions(agenticIdentity, "uploading attachment", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Bot.Core/GlobalSuppressions.cs new file mode 100644 index 00000000..480d1b6f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/GlobalSuppressions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", + "CA1873:Avoid potentially expensive logging", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Core")] + +[assembly: SuppressMessage("Performance", + "CA1848:Use the LoggerMessage delegates", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Core")] + +[assembly: SuppressMessage("Design", + "CA1054:URI-like parameters should not be strings", + Justification = "String URLs are used for consistency with existing API patterns", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Core.Http")] + +[assembly: SuppressMessage("Usage", + "CA2227:Collection properties should be read only", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Bot.Core")] diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs new file mode 100644 index 00000000..b6d689c8 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Provides extension methods for registering bot application clients and related authentication services with the +/// dependency injection container. +/// +/// This class is intended to be used during application startup to configure HTTP clients, token +/// acquisition, and agent identity services required for bot-to-bot communication. The configuration section specified +/// by the Azure Active Directory (AAD) configuration name is used to bind authentication options. Typically, these +/// methods are called in the application's service configuration pipeline. +public static class AddBotApplicationExtensions +{ + internal const string MsalConfigKey = "AzureAd"; + + /// + /// Initializes the default route + /// + /// + /// + /// + public static BotApplication UseBotApplication( + this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + => UseBotApplication(endpoints, routePath); + + /// + /// Configures the application to handle bot messages at the specified route and returns the registered bot + /// application instance. + /// + /// This method adds authentication and authorization middleware to the HTTP pipeline and maps + /// a POST endpoint for bot messages. The endpoint requires authorization. Ensure that the bot application + /// is registered in the service container before calling this method. + /// The type of the bot application to use. Must inherit from BotApplication. + /// The endpoint route builder used to configure endpoints. + /// The route path at which to listen for incoming bot messages. Defaults to "api/messages". + /// The registered bot application instance of type TApp. + /// Thrown if the bot application of type TApp is not registered in the application's service container. + public static TApp UseBotApplication( + this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + where TApp : BotApplication + { + ArgumentNullException.ThrowIfNull(endpoints); + + // Add authentication and authorization middleware to the pipeline + // This is safe because WebApplication implements both IEndpointRouteBuilder and IApplicationBuilder + if (endpoints is IApplicationBuilder app) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + TApp botApp = endpoints.ServiceProvider.GetService() ?? throw new InvalidOperationException("Application not registered"); + + endpoints.MapPost(routePath, (HttpContext httpContext, CancellationToken cancellationToken) + => botApp.ProcessAsync(httpContext, cancellationToken) + ).RequireAuthorization(); + + return botApp; + } + + /// + /// Adds a bot application to the service collection with the default configuration section name "AzureAd". + /// + /// + /// + /// + public static IServiceCollection AddBotApplication(this IServiceCollection services, string sectionName = "AzureAd") + => services.AddBotApplication(sectionName); + + /// + /// Adds a bot application to the service collection. + /// + /// + /// + /// + /// + public static IServiceCollection AddBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : BotApplication + { + // Extract ILoggerFactory from service collection to create logger without BuildServiceProvider + ServiceDescriptor? loggerFactoryDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ILoggerFactory)); + ILoggerFactory? loggerFactory = loggerFactoryDescriptor?.ImplementationInstance as ILoggerFactory; + ILogger logger = loggerFactory?.CreateLogger() + ?? (ILogger)Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + services.AddSingleton(sp => + { + IConfiguration config = sp.GetRequiredService(); + return new BotApplicationOptions + { + AppId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? string.Empty + }; + }); + services.AddHttpContextAccessor(); + services.AddBotAuthorization(logger, sectionName); + services.AddConversationClient(sectionName); + services.AddUserTokenClient(sectionName); + services.AddSingleton(); + return services; + } + + /// + /// Adds conversation client to the service collection. + /// + /// service collection + /// Configuration Section name, defaults to AzureAD + /// + public static IServiceCollection AddConversationClient(this IServiceCollection services, string sectionName = "AzureAd") => + services.AddBotClient(ConversationClient.ConversationHttpClientName, sectionName); + + /// + /// Adds user token client to the service collection. + /// + /// service collection + /// Configuration Section name, defaults to AzureAD + /// + public static IServiceCollection AddUserTokenClient(this IServiceCollection services, string sectionName = "AzureAd") => + services.AddBotClient(UserTokenClient.UserTokenHttpClientName, sectionName); + + private static IServiceCollection AddBotClient( + this IServiceCollection services, + string httpClientName, + string sectionName) where TClient : class + { + // Register options to defer scope configuration reading + services.AddOptions() + .Configure((options, configuration) => + { + options.Scope = "https://api.botframework.com/.default"; + if (!string.IsNullOrEmpty(configuration[$"{sectionName}:Scope"])) + options.Scope = configuration[$"{sectionName}:Scope"]!; + if (!string.IsNullOrEmpty(configuration["Scope"])) + options.Scope = configuration["Scope"]!; + options.SectionName = sectionName; + }); + + services + .AddHttpClient() + .AddTokenAcquisition(true) + .AddInMemoryTokenCaches() + .AddAgentIdentities(); + + // Get configuration and logger to configure MSAL during registration + // Try to get from service descriptors first + ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); + + ServiceDescriptor? loggerFactoryDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ILoggerFactory)); + ILoggerFactory? loggerFactory = loggerFactoryDescriptor?.ImplementationInstance as ILoggerFactory; + ILogger logger = loggerFactory?.CreateLogger(typeof(AddBotApplicationExtensions)) + ?? Extensions.Logging.Abstractions.NullLogger.Instance; + + // If configuration not available as instance, build temporary provider + if (configDescriptor?.ImplementationInstance is not IConfiguration configuration) + { + using ServiceProvider tempProvider = services.BuildServiceProvider(); + configuration = tempProvider.GetRequiredService(); + if (loggerFactory == null) + { + logger = tempProvider.GetRequiredService().CreateLogger(typeof(AddBotApplicationExtensions)); + } + } + + // Configure MSAL during registration (not deferred) + if (services.ConfigureMSAL(configuration, sectionName, logger)) + { + services.AddHttpClient(httpClientName) + .AddHttpMessageHandler(sp => + { + BotClientOptions botOptions = sp.GetRequiredService>().Value; + return new BotAuthenticationHandler( + sp.GetRequiredService(), + sp.GetRequiredService>(), + botOptions.Scope, + sp.GetService>()); + }); + } + else + { + _logAuthConfigNotFound(logger, null); + services.AddHttpClient(httpClientName); + } + + return services; + } + + private static bool ConfigureMSAL(this IServiceCollection services, IConfiguration configuration, string sectionName, ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + + if (configuration["MicrosoftAppId"] is not null) + { + _logUsingBFConfig(logger, null); + BotConfig botConfig = BotConfig.FromBFConfig(configuration); + services.ConfigureMSALFromBotConfig(botConfig, logger); + } + else if (configuration["CLIENT_ID"] is not null) + { + _logUsingCoreConfig(logger, null); + BotConfig botConfig = BotConfig.FromCoreConfig(configuration); + services.ConfigureMSALFromBotConfig(botConfig, logger); + } + else + { + _logUsingSectionConfig(logger, sectionName, null); + services.ConfigureMSALFromConfig(configuration.GetSection(sectionName)); + } + return true; + } + + private static IServiceCollection ConfigureMSALFromConfig(this IServiceCollection services, IConfigurationSection msalConfigSection) + { + ArgumentNullException.ThrowIfNull(msalConfigSection); + services.Configure(MsalConfigKey, msalConfigSection); + return services; + } + + private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientSecret); + + services.Configure(MsalConfigKey, options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + options.ClientCredentials = [ + new CredentialDescription() + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = clientSecret + } + ]; + }); + return services; + } + + private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection services, string tenantId, string clientId, string? ficClientId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(clientId); + + CredentialDescription ficCredential = new() + { + SourceType = CredentialSource.SignedAssertionFromManagedIdentity, + }; + if (!string.IsNullOrEmpty(ficClientId) && !IsSystemAssignedManagedIdentity(ficClientId)) + { + ficCredential.ManagedIdentityClientId = ficClientId; + } + + services.Configure(MsalConfigKey, options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + options.ClientCredentials = [ + ficCredential + ]; + }); + return services; + } + + private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection services, string tenantId, string clientId, string? managedIdentityClientId = null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNullOrWhiteSpace(clientId); + + // Register ManagedIdentityOptions for BotAuthenticationHandler to use + bool isSystemAssigned = IsSystemAssignedManagedIdentity(managedIdentityClientId); + string? umiClientId = isSystemAssigned ? null : (managedIdentityClientId ?? clientId); + + services.Configure(options => + { + options.UserAssignedClientId = umiClientId; + }); + + services.Configure(MsalConfigKey, options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = tenantId; + options.ClientId = clientId; + }); + return services; + } + + private static IServiceCollection ConfigureMSALFromBotConfig(this IServiceCollection services, BotConfig botConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(botConfig); + if (!string.IsNullOrEmpty(botConfig.ClientSecret)) + { + _logUsingClientSecret(logger, null); + services.ConfigureMSALWithSecret(botConfig.TenantId, botConfig.ClientId, botConfig.ClientSecret); + } + else if (string.IsNullOrEmpty(botConfig.FicClientId) || botConfig.FicClientId == botConfig.ClientId) + { + _logUsingUMI(logger, null); + services.ConfigureMSALWithUMI(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + } + else + { + bool isSystemAssigned = IsSystemAssignedManagedIdentity(botConfig.FicClientId); + _logUsingFIC(logger, isSystemAssigned ? "System-Assigned" : "User-Assigned", null); + services.ConfigureMSALWithFIC(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + } + return services; + } + + /// + /// Determines if the provided client ID represents a system-assigned managed identity. + /// + private static bool IsSystemAssignedManagedIdentity(string? clientId) + => string.Equals(clientId, BotConfig.SystemManagedIdentityIdentifier, StringComparison.OrdinalIgnoreCase); + + private static readonly Action _logUsingBFConfig = + LoggerMessage.Define(LogLevel.Debug, new(1), "Configuring MSAL from Bot Framework configuration"); + private static readonly Action _logUsingCoreConfig = + LoggerMessage.Define(LogLevel.Debug, new(2), "Configuring MSAL from Core bot configuration"); + private static readonly Action _logUsingSectionConfig = + LoggerMessage.Define(LogLevel.Debug, new(3), "Configuring MSAL from {SectionName} configuration section"); + private static readonly Action _logUsingClientSecret = + LoggerMessage.Define(LogLevel.Debug, new(4), "Configuring authentication with client secret"); + private static readonly Action _logUsingUMI = + LoggerMessage.Define(LogLevel.Debug, new(5), "Configuring authentication with User-Assigned Managed Identity"); + private static readonly Action _logUsingFIC = + LoggerMessage.Define(LogLevel.Debug, new(6), "Configuring authentication with Federated Identity Credential (Managed Identity) with {IdentityType} Managed Identity"); + private static readonly Action _logAuthConfigNotFound = + LoggerMessage.Define(LogLevel.Warning, new(7), "Authentication configuration not found. Running without Auth"); + + +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs new file mode 100644 index 00000000..5e23f17b --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Options for configuring a bot application instance. +/// +public sealed class BotApplicationOptions +{ + /// + /// Gets or sets the application (client) ID, used for logging and diagnostics. + /// + public string AppId { get; set; } = string.Empty; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs new file mode 100644 index 00000000..4dd8d07e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotAuthenticationHandler.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// HTTP message handler that automatically acquires and attaches authentication tokens +/// for Bot Framework API calls. Supports both app-only and agentic (user-delegated) token acquisition. +/// +/// +/// Initializes a new instance of the class. +/// +/// The authorization header provider for acquiring tokens. +/// The logger instance. +/// The scope for the token request. +/// Optional managed identity options for user-assigned managed identity authentication. +internal sealed class BotAuthenticationHandler( + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger, + string scope, + IOptions? managedIdentityOptions = null) : DelegatingHandler +{ + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + private readonly IOptions? _managedIdentityOptions = managedIdentityOptions; + private static readonly Action _logAgenticToken = + LoggerMessage.Define(LogLevel.Information, new(2), "Acquiring agentic token for AgenticAppId {AgenticAppId}"); + private static readonly Action _logAppOnlyToken = + LoggerMessage.Define(LogLevel.Information, new(3), "Acquiring app-only token for scope: {Scope}"); + private static readonly Action _logTokenClaims = + LoggerMessage.Define(LogLevel.Trace, new(4), "Acquired token claims:{Claims}"); + + /// + /// Key used to store the agentic identity in HttpRequestMessage options. + /// + public static readonly HttpRequestOptionsKey AgenticIdentityKey = new("AgenticIdentity"); + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Options.TryGetValue(AgenticIdentityKey, out AgenticIdentity? agenticIdentity); + + string token = await GetAuthorizationHeaderAsync(agenticIdentity, cancellationToken).ConfigureAwait(false); + + string tokenValue = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? token["Bearer ".Length..] + : token; + + LogTokenClaims(tokenValue); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenValue); + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets an authorization header for Bot Framework API calls. + /// Supports both app-only and agentic (user-delegated) token acquisition. + /// + /// Optional agentic identity for user-delegated token acquisition. If not provided, acquires an app-only token. + /// Cancellation token. + /// The authorization header value. + private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) + { + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = AddBotApplicationExtensions.MsalConfigKey, + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + options.AcquireTokenOptions.ManagedIdentity = miOptions; + } + } + + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null); + + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, Guid.Parse(agenticIdentity.AgenticUserId)); + string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken).ConfigureAwait(false); + return token; + } + + _logAppOnlyToken(_logger, _scope, null); + string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken).ConfigureAwait(false); + + + return appToken; + } + + private void LogTokenClaims(string token) + { + if (!_logger.IsEnabled(LogLevel.Trace)) + { + return; + } + + + JwtSecurityToken jwtToken = new(token); + string claims = Environment.NewLine + string.Join(Environment.NewLine, jwtToken.Claims.Select(c => $" {c.Type}: {c.Value}")); + _logTokenClaims(_logger, claims, null); + + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs new file mode 100644 index 00000000..6316cc75 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Options for configuring bot client HTTP clients. +/// +internal sealed class BotClientOptions +{ + /// + /// Gets or sets the scope for bot authentication. + /// + public string Scope { get; set; } = "https://api.botframework.com/.default"; + + /// + /// Gets or sets the configuration section name. + /// + public string SectionName { get; set; } = "AzureAd"; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs new file mode 100644 index 00000000..48386ced --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Configuration model for bot authentication credentials. +/// +/// +/// This class consolidates bot authentication settings from various configuration sources including +/// Bot Framework SDK configuration, Core configuration, and Azure AD configuration sections. +/// It supports multiple authentication modes: client secrets, system-assigned managed identities, +/// user-assigned managed identities, and federated identity credentials (FIC). +/// +internal sealed class BotConfig +{ + /// + /// Identifier used to specify system-assigned managed identity authentication. + /// When FicClientId equals this value, the system will use the system-assigned managed identity. + /// + public const string SystemManagedIdentityIdentifier = "system"; + + /// + /// Gets or sets the Azure AD tenant ID. + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// Gets or sets the application (client) ID from Azure AD app registration. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the client secret for client credentials authentication. + /// Optional if using managed identity or federated identity credentials. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the client ID for federated identity credentials or user-assigned managed identity. + /// Use to specify system-assigned managed identity. + /// + public string? FicClientId { get; set; } + + /// + /// Creates a BotConfig from Bot Framework SDK configuration format. + /// + /// Configuration containing MicrosoftAppId, MicrosoftAppPassword, and MicrosoftAppTenantId settings. + /// A new BotConfig instance with settings from Bot Framework configuration. + /// Thrown when is null. + public static BotConfig FromBFConfig(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new() + { + TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty, + ClientId = configuration["MicrosoftAppId"] ?? string.Empty, + ClientSecret = configuration["MicrosoftAppPassword"], + }; + } + + /// + /// Creates a BotConfig from Teams Bot Core environment variable format. + /// + /// Configuration containing TENANT_ID, CLIENT_ID, CLIENT_SECRET, and MANAGED_IDENTITY_CLIENT_ID settings. + /// A new BotConfig instance with settings from Core configuration. + /// Thrown when is null. + /// + /// This format is typically used with environment variables in containerized deployments. + /// The MANAGED_IDENTITY_CLIENT_ID can be set to "system" for system-assigned managed identity. + /// + public static BotConfig FromCoreConfig(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new() + { + TenantId = configuration["TENANT_ID"] ?? string.Empty, + ClientId = configuration["CLIENT_ID"] ?? string.Empty, + ClientSecret = configuration["CLIENT_SECRET"], + FicClientId = configuration["MANAGED_IDENTITY_CLIENT_ID"], + }; + } + + /// + /// Creates a BotConfig from Azure AD configuration section format. + /// + /// Configuration containing an Azure AD configuration section. + /// The name of the configuration section containing Azure AD settings. Defaults to "AzureAd". + /// A new BotConfig instance with settings from the Azure AD configuration section. + /// Thrown when is null. + /// + /// This format is compatible with Microsoft.Identity.Web configuration sections in appsettings.json. + /// The section should contain TenantId, ClientId, and optionally ClientSecret properties. + /// + public static BotConfig FromAadConfig(IConfiguration configuration, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(configuration); + IConfigurationSection section = configuration.GetSection(sectionName); + return new() + { + TenantId = section["TenantId"] ?? string.Empty, + ClientId = section["ClientId"] ?? string.Empty, + ClientSecret = section["ClientSecret"], + }; + } + + /// + /// Resolves a BotConfig by trying all configuration formats in priority order: + /// AzureAd section, Core environment variables, then Bot Framework SDK keys. + /// + /// The application configuration. + /// The AAD configuration section name. Defaults to "AzureAd". + /// The first BotConfig with a non-empty ClientId. + /// Thrown when no ClientId is found in any configuration format. + public static BotConfig Resolve(IConfiguration configuration, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(configuration); + + BotConfig config = FromAadConfig(configuration, sectionName); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + config = FromCoreConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + config = FromBFConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + throw new InvalidOperationException("ClientID not found in configuration."); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs new file mode 100644 index 00000000..76cc6f60 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; + +namespace Microsoft.Teams.Bot.Core.Hosting +{ + /// + /// Provides extension methods for configuring JWT authentication and authorization for bots and agents. + /// + public static class JwtExtensions + { + internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; + internal const string EntraOIDC = "https://login.microsoftonline.com/"; + + /// + /// Adds JWT authentication for bots and agents. + /// + /// The service collection to add authentication to. + /// The configuration section name for the settings. Defaults to "AzureAd". + /// The logger instance for logging. + /// An for further authentication configuration. + public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, ILogger logger, string aadSectionName = "AzureAd") + { + AuthenticationBuilder builder = services.AddAuthentication(); + + ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); + IConfiguration configuration = configDescriptor?.ImplementationInstance as IConfiguration + ?? services.BuildServiceProvider().GetRequiredService(); + + BotConfig botConfig = BotConfig.Resolve(configuration, aadSectionName); + + builder.AddTeamsJwtBearer(aadSectionName, botConfig.ClientId, botConfig.TenantId, logger); + + return builder; + } + + /// + /// Adds authorization policies to the service collection. + /// + /// The service collection to add authorization to. + /// The configuration section name for the settings. Defaults to "AzureAd". + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// An for further authorization configuration. + public static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, ILogger? logger = null, string aadSectionName = "AzureAd") + { + logger ??= NullLogger.Instance; + + services.AddBotAuthentication(logger, aadSectionName); + + return services + .AddAuthorizationBuilder() + .AddDefaultPolicy(aadSectionName, policy => + { + policy.AuthenticationSchemes.Add(aadSectionName); + policy.RequireAuthenticatedUser(); + }); + } + + private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId) + { + // Bot Framework tokens + if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) + return issuer; + + // Entra tokens � bot-to-bot (agent) and user (tab/API) + // Use the token's own tid claim for multi-tenant; fall back to configured tenant + (_, string? tid) = GetTokenClaims(token); + string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId; + + if (effectiveTenant is not null && + (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" || + issuer == $"https://sts.windows.net/{effectiveTenant}/")) + return issuer; + + throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid."); + } + + private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => + token is JsonWebToken jwt + ? (jwt.Issuer, jwt.TryGetClaim("tid", out var c) ? c.Value : null) + : (null, null); + + private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger) + { + // One ConfigurationManager per OIDC authority, shared safely across all requests. + ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); + + builder.AddJwtBearer(schemeName, jwtOptions => + { + jwtOptions.SaveToken = true; + jwtOptions.IncludeErrorDetails = true; + jwtOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidAudiences = [audience, $"api://{audience}"], + IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId), + IssuerSigningKeyResolver = (_, securityToken, _, _) => + { + (string? iss, string? tid) = GetTokenClaims(securityToken); + if (iss is null) return []; + + string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) + ? BotOIDC + : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; + + ConfigurationManager manager = configManagerCache.GetOrAdd(authority, a => + new ConfigurationManager( + a, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata })); + + OpenIdConnectConfiguration config = manager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult(); + return config.SigningKeys; + } + }; + jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + jwtOptions.MapInboundClaims = true; + jwtOptions.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + GetLogger(context.HttpContext, logger).LogDebug("Token validated for scheme: {Scheme}", schemeName); + return Task.CompletedTask; + }, + OnForbidden = context => + { + GetLogger(context.HttpContext, logger).LogWarning("Forbidden for scheme: {Scheme}", schemeName); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + ILogger log = GetLogger(context.HttpContext, logger); + + string? tokenIssuer = null; + string? tokenAudience = null; + string? tokenExpiration = null; + string? tokenSubject = null; + string authHeader = context.Request.Headers.Authorization.ToString(); + if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + try + { + JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim()); + (tokenIssuer, _) = GetTokenClaims(jwt); + tokenAudience = jwt.GetClaim("aud")?.Value; + tokenExpiration = jwt.ValidTo.ToString("o"); + tokenSubject = jwt.Subject; + } + catch (ArgumentException) { } + } + + TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters; + string expectedAudiences = validationParams?.ValidAudiences is not null + ? string.Join(", ", validationParams.ValidAudiences) + : validationParams?.ValidAudience ?? "n/a"; + log.LogError(context.Exception, + "JWT authentication failed for scheme {Scheme}: {ExceptionMessage} | " + + "token iss={TokenIssuer} aud={TokenAudience} exp={TokenExpiration} sub={TokenSubject} | " + + "expected aud={ConfiguredAudience}", + schemeName, + context.Exception.Message, + tokenIssuer ?? "n/a", + tokenAudience ?? "n/a", + tokenExpiration ?? "n/a", + tokenSubject ?? "n/a", + expectedAudiences); + + return Task.CompletedTask; + } + }; + jwtOptions.Validate(); + }); + return builder; + } + + private static ILogger GetLogger(HttpContext context, ILogger? fallback) => + context.RequestServices.GetService()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") + ?? fallback + ?? NullLogger.Instance; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs new file mode 100644 index 00000000..f972895d --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Http/BotHttpClient.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Core.Http; +/// +/// Provides shared HTTP request functionality for bot clients. +/// +/// The HTTP client instance used to send requests. +/// The logger instance used for logging. Optional. +public class BotHttpClient(HttpClient httpClient, ILogger? logger = null) +{ + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Sends an HTTP request and deserializes the response. + /// + /// The type to deserialize the response to. + /// The HTTP method to use. + /// The full URL for the request. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). + /// Thrown if the request fails and the failure is not handled by options. + public async Task SendAsync( + HttpMethod method, + string url, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new BotRequestOptions(); + + using HttpRequestMessage request = CreateRequest(method, url, body, options); + + logger?.LogTrace("Sending HTTP {Method} request to {Url} with body: {Body}", method, url, body); + + using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + return await HandleResponseAsync(response, method, url, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request with query parameters and deserializes the response. + /// + /// The type to deserialize the response to. + /// The HTTP method to use. + /// The base URL for the request. + /// The endpoint path to append to the base URL. + /// The query parameters to include in the request. Optional. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the response is empty or 404 (when ReturnNullOnNotFound is true). + /// Thrown if the request fails and the failure is not handled by options. + public async Task SendAsync( + HttpMethod method, + string baseUrl, + string endpoint, + Dictionary? queryParams = null, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseUrl); + ArgumentNullException.ThrowIfNull(endpoint); + + string fullPath = $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}"; + string url = queryParams?.Count > 0 + ? QueryHelpers.AddQueryString(fullPath, queryParams) + : fullPath; + + return await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request without expecting a response body. + /// + /// The HTTP method to use. + /// The full URL for the request. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the request fails. + public async Task SendAsync( + HttpMethod method, + string url, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + await SendAsync(method, url, body, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an HTTP request with query parameters without expecting a response body. + /// + /// The HTTP method to use. + /// The base URL for the request. + /// The endpoint path to append to the base URL. + /// The query parameters to include in the request. Optional. + /// The request body content. Optional. + /// The request options. Optional. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the request fails. + public async Task SendAsync( + HttpMethod method, + string baseUrl, + string endpoint, + Dictionary? queryParams = null, + string? body = null, + BotRequestOptions? options = null, + CancellationToken cancellationToken = default) + { + await SendAsync(method, baseUrl, endpoint, queryParams, body, options, cancellationToken).ConfigureAwait(false); + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? body, BotRequestOptions options) + { + HttpRequestMessage request = new(method, url); + + if (body is not null) + { + request.Content = new StringContent(body, Encoding.UTF8, MediaTypeNames.Application.Json); + } + + if (options.AgenticIdentity is not null) + { + request.Options.Set(BotAuthenticationHandler.AgenticIdentityKey, options.AgenticIdentity); + } + + if (options.DefaultHeaders is not null) + { + foreach (KeyValuePair header in options.DefaultHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + if (options.CustomHeaders is not null) + { + foreach (KeyValuePair header in options.CustomHeaders) + { + request.Headers.Remove(header.Key); + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return request; + } + + private async Task HandleResponseAsync( + HttpResponseMessage response, + HttpMethod method, + string url, + BotRequestOptions options, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return await DeserializeResponseAsync(response, options, cancellationToken).ConfigureAwait(false); + } + + if (response.StatusCode == HttpStatusCode.NotFound && options.ReturnNullOnNotFound) + { + logger?.LogWarning("Resource not found: {Url}", url); + return default; + } + + string errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + string responseHeaders = FormatResponseHeaders(response); + + logger?.LogWarning( + "HTTP request error {Method} {Url}\nStatus Code: {StatusCode}\nResponse Headers: {ResponseHeaders}\nResponse Body: {ResponseBody}", + method, url, response.StatusCode, responseHeaders, errorContent); + + string operationDescription = options.OperationDescription ?? "request"; + throw new HttpRequestException( + $"Error {operationDescription} {response.StatusCode}. {errorContent}", + inner: null, + statusCode: response.StatusCode); + } + + private static async Task DeserializeResponseAsync( + HttpResponseMessage response, + BotRequestOptions options, + CancellationToken cancellationToken) + { + string responseString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(responseString) || responseString.Length <= 2) + { + return default; + } + + if (typeof(T) == typeof(string)) + { + try + { + T? result = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); + return result ?? (T)(object)responseString; + } + catch (JsonException) + { + return (T)(object)responseString; + } + } + + T? deserializedResult = JsonSerializer.Deserialize(responseString, DefaultJsonOptions); + + if (deserializedResult is null) + { + string operationDescription = options.OperationDescription ?? "request"; + throw new InvalidOperationException($"Failed to deserialize response for {operationDescription}"); + } + + return deserializedResult; + } + + private static string FormatResponseHeaders(HttpResponseMessage response) + { + StringBuilder sb = new(); + + foreach (KeyValuePair> header in response.Headers) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Response header: {header.Key} : {string.Join(",", header.Value)}"); + } + + foreach (KeyValuePair> header in response.TrailingHeaders) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"Response trailing header: {header.Key} : {string.Join(",", header.Value)}"); + } + + return sb.ToString(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs new file mode 100644 index 00000000..cbeccb52 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Http/BotRequestOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.Http; + +using CustomHeaders = Dictionary; + +/// +/// Options for configuring a bot HTTP request. +/// +public record BotRequestOptions +{ + /// + /// Gets the agentic identity for authentication. + /// + public AgenticIdentity? AgenticIdentity { get; init; } + + /// + /// Gets the custom headers to include in the request. + /// These headers override default headers if the same key exists. + /// + public CustomHeaders? CustomHeaders { get; init; } + + /// + /// Gets the default custom headers that will be included in all requests. + /// + public CustomHeaders? DefaultHeaders { get; init; } + + /// + /// Gets a value indicating whether to return null instead of throwing on 404 responses. + /// + public bool ReturnNullOnNotFound { get; init; } + + /// + /// Gets a description of the operation for logging and error messages. + /// + public string? OperationDescription { get; init; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/HttpRequestExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/HttpRequestExtensions.cs new file mode 100644 index 00000000..5d027b9f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/HttpRequestExtensions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Extension methods for . +/// +public static class HttpRequestExtensions +{ + /// + /// Gets the Microsoft Correlation Vector (MS-CV) from the request headers, if present. + /// + public static string? GetCorrelationVector(this HttpRequest request) + => request != null ? request.Headers["MS-CV"].FirstOrDefault() : string.Empty; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs b/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs new file mode 100644 index 00000000..8dc1dfc0 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/ITurnMiddleWare.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Represents a delegate that invokes the next middleware component in the pipeline asynchronously. +/// +/// This delegate is typically used in middleware scenarios to advance the request processing pipeline. +/// The cancellation token should be observed to support cooperative cancellation. +/// A cancellation token that can be used to cancel the asynchronous operation. +/// A task that represents the completion of the middleware invocation. +public delegate Task NextTurn(CancellationToken cancellationToken); + +/// +/// Defines a middleware component that can process or modify activities during a bot turn. +/// +/// Implement this interface to add custom logic before or after the bot processes an activity. +/// Middleware can perform tasks such as logging, authentication, or altering activities. Multiple middleware components +/// can be chained together; each should call the nextTurn delegate to continue the pipeline. +public interface ITurnMiddleware +{ + /// + /// Triggers the middleware to process an activity during a bot turn. + /// + /// + /// + /// + /// + /// + Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default); +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj new file mode 100644 index 00000000..210e9608 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net10.0 + enable + enable + True + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs new file mode 100644 index 00000000..ea381801 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ActivityType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Provides constant values that represent activity types used in messaging workflows. +/// +/// Use the fields of this class to specify or compare activity types in message-based systems. This +/// class is typically used to avoid hardcoding string literals for activity type identifiers. +public static class ActivityType +{ + /// + /// Represents the default message string used for communication or display purposes. + /// + public const string Message = "message"; + /// + /// Represents a typing indicator activity. + /// + public const string Typing = "typing"; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs new file mode 100644 index 00000000..51c54329 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/AgenticIdentity.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents an agentic identity for user-delegated token acquisition. +/// +public sealed class AgenticIdentity +{ + /// + /// Agentic application ID. + /// + public string? AgenticAppId { get; set; } + /// + /// Agentic user ID. + /// + public string? AgenticUserId { get; set; } + + /// + /// Agentic application blueprint ID. + /// + public string? AgenticAppBlueprintId { get; set; } + + /// + /// Creates an instance from the provided properties dictionary. + /// + /// + /// + public static AgenticIdentity? FromProperties(ExtendedPropertiesDictionary? properties) + { + if (properties is null) + { + return null; + } + + properties.TryGetValue("agenticAppId", out object? appIdObj); + properties.TryGetValue("agenticUserId", out object? userIdObj); + properties.TryGetValue("agenticAppBlueprintId", out object? bluePrintObj); + return new AgenticIdentity + { + AgenticAppId = appIdObj?.ToString(), + AgenticUserId = userIdObj?.ToString(), + AgenticAppBlueprintId = bluePrintObj?.ToString() + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs new file mode 100644 index 00000000..214c14a2 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ChannelData.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents channel-specific data associated with an activity. +/// +/// +/// This class serves as a container for custom properties that are specific to a particular +/// messaging channel. The properties dictionary allows channels to include additional metadata +/// that is not part of the standard activity schema. +/// +public class ChannelData +{ + /// + /// Gets the extension data dictionary for storing channel-specific properties. + /// + [JsonExtensionData] + public ExtendedPropertiesDictionary Properties { get; set; } = []; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs new file mode 100644 index 00000000..b10a11ed --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/Conversation.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a conversation, including its unique identifier and associated extended properties. +/// +public class Conversation() +{ + /// + /// Gets or sets the unique identifier for the object. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] + public ExtendedPropertiesDictionary Properties { get; set; } = []; +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs new file mode 100644 index 00000000..daac2813 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/ConversationAccount.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a conversation account, including its unique identifier, display name, and any additional properties +/// associated with the conversation. +/// +/// This class is typically used to model the account information for a conversation in messaging or chat +/// applications. The additional properties dictionary allows for extensibility to support custom metadata or +/// protocol-specific fields. +public class ConversationAccount() +{ + /// + /// Gets or sets the unique identifier for the object. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the display name of the conversation account. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] + public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// Gets the agentic identity from the account properties. + /// + /// An AgenticIdentity instance if properties contain agentic identity information; otherwise, null. + internal AgenticIdentity? GetAgenticIdentity() + { + Properties.TryGetValue("agenticAppId", out object? appIdObj); + Properties.TryGetValue("agenticUserId", out object? userIdObj); + Properties.TryGetValue("agenticAppBlueprintId", out object? bluePrintObj); + + if (appIdObj is null && userIdObj is null && bluePrintObj is null) + { + return null; + } + + return new AgenticIdentity + { + AgenticAppId = appIdObj?.ToString(), + AgenticUserId = userIdObj?.ToString(), + AgenticAppBlueprintId = bluePrintObj?.ToString() + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs new file mode 100644 index 00000000..0cc30d63 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivity.cs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Represents a dictionary for storing extended properties as key-value pairs. +/// +public class ExtendedPropertiesDictionary : Dictionary { } + +/// +/// Represents a core activity object that encapsulates the data and metadata for a bot interaction. +/// +/// +/// This class provides the foundational structure for bot activities including message exchanges, +/// conversation updates, and other bot-related events. It supports serialization to and from JSON +/// and includes extension properties for channel-specific data. +/// Follows the Activity Protocol Specification: https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md +/// +public class CoreActivity +{ + /// + /// Gets or sets the type of the activity. See for common values. + /// + /// + /// Common activity types include "message", "conversationUpdate", "contactRelationUpdate", etc. + /// + [JsonPropertyName("type")] public string Type { get; set; } + /// + /// Gets or sets the unique identifier for the channel on which this activity is occurring. + /// + [JsonPropertyName("channelId")] public string? ChannelId { get; set; } + /// + /// Gets or sets the unique identifier for the activity. + /// + [JsonPropertyName("id")] public string? Id { get; set; } + /// + /// Gets or sets the URL of the service endpoint for this activity. + /// + /// + /// This URL is used to send responses back to the channel. + /// + [JsonPropertyName("serviceUrl")] public Uri? ServiceUrl { get; set; } + /// + /// Gets or sets channel-specific data associated with this activity. + /// + [JsonPropertyName("channelData")] public ChannelData? ChannelData { get; set; } + /// + /// Gets or sets the account that sent this activity. + /// + [JsonPropertyName("from")] public ConversationAccount? From { get; set; } + /// + /// Gets or sets the account that should receive this activity. + /// + [JsonPropertyName("recipient")] public ConversationAccount? Recipient { get; set; } + /// + /// Gets or sets the conversation in which this activity is taking place. + /// + [JsonPropertyName("conversation")] public Conversation? Conversation { get; set; } + + /// + /// Gets the collection of entities contained in this activity. + /// + /// + /// Entities are structured objects that represent mentions, places, or other data. + /// + [JsonPropertyName("entities")] public JsonArray? Entities { get; set; } + + /// + /// Gets the collection of attachments associated with this activity. + /// + [JsonPropertyName("attachments")] public JsonArray? Attachments { get; set; } + + // TODO: Can value need be a JSONObject? + /// + /// Gets or sets the value payload of the activity. + /// + [JsonPropertyName("value")] public JsonNode? Value { get; set; } + + /// + /// Reply to Id + /// + [JsonPropertyName("replyToId")] public string? ReplyToId { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// Gets the default JSON serializer options used for serializing and deserializing activities. + /// + /// + /// Uses the source-generated JSON context for AOT-compatible serialization. + /// + public static readonly JsonSerializerOptions DefaultJsonOptions = CoreActivityJsonContext.Default.Options; + + /// + /// Gets the JSON serializer options used for reflection-based serialization of extended activity types. + /// + /// + /// Uses reflection-based serialization to support custom activity types that extend CoreActivity. + /// This is used when serializing/deserializing types not registered in the source-generated context. + /// + private static readonly JsonSerializerOptions ReflectionJsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Creates a new instance of the class with the specified activity type. + /// + /// + public CoreActivity(string type = ActivityType.Message) + { + Type = type; + } + + + /// + /// Creates a new instance of the class. As Message type by default. + /// + public CoreActivity() + { + Type = ActivityType.Message; + } + + /// + /// Creates a new instance of the class by copying properties from another activity. + /// + /// The source activity to copy from. + protected CoreActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + Id = activity.Id; + ServiceUrl = activity.ServiceUrl; + ChannelId = activity.ChannelId; + Type = activity.Type; + // TODO: Figure out why this is needed... + // ReplyToId = activity.ReplyToId; + ChannelData = activity.ChannelData; + From = activity.From; + Recipient = activity.Recipient; + Conversation = activity.Conversation; + Entities = activity.Entities; + Attachments = activity.Attachments; + Properties = activity.Properties; + Value = activity.Value; + } + + /// + /// Serializes the current activity to a JSON string. + /// + /// A JSON string representation of the activity. + public virtual string ToJson() + => JsonSerializer.Serialize(this, CoreActivityJsonContext.Default.CoreActivity); + + /// + /// Serializes the current activity to a JSON string using the specified JsonTypeInfo options. + /// + /// + /// + /// + public string ToJson(JsonTypeInfo ops) where T : CoreActivity + => JsonSerializer.Serialize(this, ops); + + /// + /// Serializes the specified activity instance to a JSON string using the default serialization options. + /// + /// The serialization uses the default JSON options defined by DefaultJsonOptions. The resulting + /// JSON reflects the public properties of the activity instance. + /// The type of the activity to serialize. Must inherit from CoreActivity. + /// The activity instance to serialize. Cannot be null. + /// A JSON string representation of the specified activity instance. + public static string ToJson(T instance) where T : CoreActivity + => JsonSerializer.Serialize(instance, ReflectionJsonOptions); + + /// + /// Deserializes a JSON string into a object. + /// + /// The JSON string to deserialize. + /// A instance. + public static CoreActivity FromJsonString(string json) + => JsonSerializer.Deserialize(json, CoreActivityJsonContext.Default.CoreActivity)!; + + /// + /// Asynchronously deserializes a JSON stream into a object. + /// + /// The stream containing JSON data to deserialize. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the deserialized instance, or null if deserialization fails. + public static ValueTask FromJsonStreamAsync(Stream stream, CancellationToken cancellationToken = default) + => JsonSerializer.DeserializeAsync(stream, CoreActivityJsonContext.Default.CoreActivity, cancellationToken); + + /// + /// Deserializes a JSON stream into an instance of type T using the specified JsonTypeInfo options. + /// + /// + /// + /// + /// + /// + public static ValueTask FromJsonStreamAsync(Stream stream, JsonTypeInfo ops, CancellationToken cancellationToken = default) where T : CoreActivity + => JsonSerializer.DeserializeAsync(stream, ops, cancellationToken); + + /// + /// Asynchronously deserializes a JSON value from the specified stream into an instance of type T. + /// + /// The caller is responsible for managing the lifetime of the provided stream. The method uses + /// default JSON serialization options. + /// The type of the object to deserialize. Must derive from CoreActivity. + /// The stream containing the JSON data to deserialize. The stream must be readable and positioned at the start of + /// the JSON content. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. The result contains an instance of type T if + /// deserialization is successful; otherwise, null. + public static ValueTask FromJsonStreamAsync(Stream stream, CancellationToken cancellationToken = default) where T : CoreActivity + => JsonSerializer.DeserializeAsync(stream, ReflectionJsonOptions, cancellationToken); + + /// + /// Creates a new instance of the to construct activity instances. + /// + /// + public static CoreActivityBuilder CreateBuilder() => new(); + +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs new file mode 100644 index 00000000..cf65c86f --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityBuilder.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// Provides a fluent API for building CoreActivity instances. +/// +/// The type of activity being built. +/// The type of the builder (for fluent method chaining). +public abstract class CoreActivityBuilder + where TActivity : CoreActivity + where TBuilder : CoreActivityBuilder +{ + /// + /// The activity being built. + /// +#pragma warning disable CA1051 // Do not declare visible instance fields + protected readonly TActivity _activity; +#pragma warning restore CA1051 // Do not declare visible instance fields + + /// + /// Initializes a new instance of the CoreActivityBuilder class. + /// + /// The activity to build upon. + protected CoreActivityBuilder(TActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + _activity = activity; + } + + /// + /// Apply Conversation Reference + /// + /// The source activity to copy conversation reference from. + /// The builder instance for chaining. + public TBuilder WithConversationReference(TActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(activity.ChannelId); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + ArgumentNullException.ThrowIfNull(activity.Conversation); + ArgumentNullException.ThrowIfNull(activity.From); + ArgumentNullException.ThrowIfNull(activity.Recipient); + + WithServiceUrl(activity.ServiceUrl); + WithChannelId(activity.ChannelId); + SetConversation(activity.Conversation); + SetFrom(activity.Recipient); + SetRecipient(activity.From); + + if (!string.IsNullOrEmpty(activity.Id)) + { + WithReplyToId(activity.Id); + } + + return (TBuilder)this; + } + + /// + /// Sets the conversation (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetConversation(Conversation? conversation); + + /// + /// Sets the From account (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetFrom(ConversationAccount? from); + + /// + /// Sets the Recipient account (to be overridden by derived classes for type-specific behavior). + /// + protected abstract void SetRecipient(ConversationAccount? recipient); + + /// + /// Sets the activity ID. + /// + /// The activity ID. + /// The builder instance for chaining. + public TBuilder WithId(string id) + { + _activity.Id = id; + return (TBuilder)this; + } + + /// + /// Sets the Reply to id + /// + /// + /// + public TBuilder WithReplyToId(string replyToId) + { + _activity.ReplyToId = replyToId; + return (TBuilder)this; + } + + /// + /// Sets the service URL. + /// + /// The service URL. + /// The builder instance for chaining. + public TBuilder WithServiceUrl(Uri serviceUrl) + { + _activity.ServiceUrl = serviceUrl; + return (TBuilder)this; + } + + /// + /// Sets the channel ID. + /// + /// The channel ID. + /// The builder instance for chaining. + public TBuilder WithChannelId(string channelId) + { + _activity.ChannelId = channelId; + return (TBuilder)this; + } + + /// + /// Sets the activity type. + /// + /// The activity type. + /// The builder instance for chaining. + public TBuilder WithType(string type) + { + _activity.Type = type; + return (TBuilder)this; + } + + /// + /// Adds or updates a property in the activity's Properties dictionary. + /// + /// Name of the property. + /// Value of the property. + /// The builder instance for chaining. + public TBuilder WithProperty(string name, T? value) + { + _activity.Properties[name] = value; + return (TBuilder)this; + } + + /// + /// Sets the sender account information. + /// + /// The sender account. + /// The builder instance for chaining. + public TBuilder WithFrom(ConversationAccount? from) + { + SetFrom(from); + return (TBuilder)this; + } + + /// + /// Sets the recipient account information. + /// + /// The recipient account. + /// The builder instance for chaining. + public TBuilder WithRecipient(ConversationAccount? recipient) + { + SetRecipient(recipient); + return (TBuilder)this; + } + + /// + /// Sets the conversation information. + /// + /// The conversation information. + /// The builder instance for chaining. + public TBuilder WithConversation(Conversation? conversation) + { + SetConversation(conversation); + return (TBuilder)this; + } + + /// + /// Sets the channel-specific data (to be overridden by derived classes for type-specific behavior). + /// + /// The channel data. + /// The builder instance for chaining. + public virtual TBuilder WithChannelData(ChannelData? channelData) + { + _activity.ChannelData = channelData; + return (TBuilder)this; + } + + /// + /// Builds and returns the configured activity instance. + /// + /// The configured activity. + public abstract TActivity Build(); +} + +/// +/// Provides a fluent API for building CoreActivity instances. +/// +public class CoreActivityBuilder : CoreActivityBuilder +{ + /// + /// Initializes a new instance of the CoreActivityBuilder class. + /// + internal CoreActivityBuilder() : base(new CoreActivity()) + { + } + + /// + /// Initializes a new instance of the CoreActivityBuilder class with an existing activity. + /// + /// The activity to build upon. + internal CoreActivityBuilder(CoreActivity activity) : base(activity) + { + } + + /// + /// Sets the conversation. + /// + protected override void SetConversation(Conversation? conversation) + { + _activity.Conversation = conversation; + } + + /// + /// Sets the From account. + /// + protected override void SetFrom(ConversationAccount? from) + { + _activity.From = from; + } + + /// + /// Sets the Recipient account. + /// + protected override void SetRecipient(ConversationAccount? recipient) + { + _activity.Recipient = recipient; + } + + /// + /// Builds and returns the configured CoreActivity instance. + /// + /// The configured CoreActivity. + public override CoreActivity Build() + { + return _activity; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs new file mode 100644 index 00000000..e8360273 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Schema/CoreActivityJsonContext.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core.Schema; + +/// +/// JSON source generator context for Core activity types. +/// This enables AOT-compatible and reflection-free JSON serialization. +/// +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(ChannelData))] +[JsonSerializable(typeof(Conversation))] +[JsonSerializable(typeof(ConversationAccount))] +[JsonSerializable(typeof(ExtendedPropertiesDictionary))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Int32))] +[JsonSerializable(typeof(System.Boolean))] +[JsonSerializable(typeof(System.Int64))] +[JsonSerializable(typeof(System.Double))] +public partial class CoreActivityJsonContext : JsonSerializerContext +{ +} diff --git a/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs b/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs new file mode 100644 index 00000000..f0b996b1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/TurnMiddleware.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Manages and executes a middleware pipeline for processing bot turns. +/// +/// +/// This class implements a chain of responsibility pattern where each middleware component can process +/// an activity before passing control to the next middleware in the pipeline. The pipeline executes +/// sequentially, with each middleware having the opportunity to modify the activity, perform side effects, +/// or short-circuit the pipeline. Middleware is executed in the order it was registered via the Use method. +/// +internal sealed class TurnMiddleware : ITurnMiddleware, IEnumerable +{ + private readonly IList _middlewares = []; + + /// + /// Adds a middleware component to the end of the pipeline. + /// + /// The middleware to add. Cannot be null. + /// The current TurnMiddleware instance for method chaining. + internal TurnMiddleware Use(ITurnMiddleware middleware) + { + _middlewares.Add(middleware); + return this; + } + + /// + /// Processes a turn by executing the middleware pipeline. + /// + /// The bot application processing the turn. + /// The activity to process. + /// Delegate to invoke the next middleware in the outer pipeline. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous pipeline execution. + public async Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn next, CancellationToken cancellationToken = default) + { + await RunPipelineAsync(botApplication, activity, null!, 0, cancellationToken).ConfigureAwait(false); + await next(cancellationToken).ConfigureAwait(false); + } + + /// + /// Recursively executes the middleware pipeline starting from the specified index. + /// + /// The bot application processing the turn. + /// The activity to process. + /// Optional callback to invoke after all middleware has executed. + /// The index of the next middleware to execute in the pipeline. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous pipeline execution. + public Task RunPipelineAsync(BotApplication botApplication, CoreActivity activity, Func? callback, int nextMiddlewareIndex, CancellationToken cancellationToken) + { + if (nextMiddlewareIndex == _middlewares.Count) + { + return callback is not null ? callback!(activity, cancellationToken) ?? Task.CompletedTask : Task.CompletedTask; + } + ITurnMiddleware nextMiddleware = _middlewares[nextMiddlewareIndex]; + return nextMiddleware.OnTurnAsync( + botApplication, + activity, + (ct) => RunPipelineAsync(botApplication, activity, callback, nextMiddlewareIndex + 1, ct), + cancellationToken); + } + + public IEnumerator GetEnumerator() + { + return _middlewares.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs new file mode 100644 index 00000000..258aebc1 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.Models.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Bot.Core; + + +/// +/// Result object for GetTokenStatus API call. +/// +public class GetTokenStatusResult +{ + /// + /// The connection name associated with the token. + /// + public string? ConnectionName { get; set; } + /// + /// Indicates whether a token is available. + /// + public bool? HasToken { get; set; } + /// + /// The display name of the service provider. + /// + public string? ServiceProviderDisplayName { get; set; } +} + +/// +/// Result object for GetToken API call. +/// +public class GetTokenResult +{ + /// + /// The connection name associated with the token. + /// + public string? ConnectionName { get; set; } + /// + /// The token string. + /// + public string? Token { get; set; } +} + +/// +/// SignIn resource object. +/// +public class GetSignInResourceResult +{ + /// + /// The link for signing in. + /// + public string? SignInLink { get; set; } + /// + /// The resource for token post. + /// + public TokenPostResource? TokenPostResource { get; set; } + + /// + /// The token exchange resources. + /// + public TokenExchangeResource? TokenExchangeResource { get; set; } +} +/// +/// Token post resource object. +/// +public class TokenPostResource +{ + /// + /// The URL to which the token should be posted. + /// + public Uri? SasUrl { get; set; } +} + +/// +/// Token exchange resource object. +/// +public class TokenExchangeResource +{ + /// + /// ID of the token exchange resource. + /// + public string? Id { get; set; } + /// + /// Provider ID of the token exchange resource. + /// + public string? ProviderId { get; set; } + /// + /// URI of the token exchange resource. + /// + public Uri? Uri { get; set; } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs new file mode 100644 index 00000000..e71e2c50 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Http; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core; + +/// +/// Client for managing user tokens via HTTP requests to the Bot Framework Token Service. +/// +/// +/// This client provides methods for OAuth token management including retrieving tokens, exchanging tokens, +/// signing out users, and managing AAD tokens. The client communicates with the Bot Framework Token Service +/// API endpoint (defaults to https://token.botframework.com but can be configured via UserTokenApiEndpoint). +/// +/// The HTTP client for making requests to the token service. +/// Configuration containing the UserTokenApiEndpoint setting and other bot configuration. +/// Logger for diagnostic information and request tracking. +public class UserTokenClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) +{ + internal const string UserTokenHttpClientName = "BotUserTokenClient"; + private readonly ILogger _logger = logger; + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] ?? "https://token.botframework.com"; + private readonly JsonSerializerOptions _defaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + internal AgenticIdentity? AgenticIdentity { get; set; } + + /// + /// Gets the token status for each connection for the given user. + /// + /// The user ID. + /// The channel ID. + /// The optional include parameter. + /// The cancellation token. + /// + public virtual async Task GetTokenStatusAsync(string userId, string channelId, string? include = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "channelId", channelId } + }; + + if (!string.IsNullOrEmpty(include)) + { + queryParams.Add("include", include); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/GetTokenStatus"); + IList? result = await _botHttpClient.SendAsync>( + HttpMethod.Get, + _apiEndpoint, + "api/usertoken/GetTokenStatus", + queryParams, + body: null, + CreateRequestOptions("getting token status"), + cancellationToken).ConfigureAwait(false); + + if (result == null || result.Count == 0) + { + return [new GetTokenStatusResult { HasToken = false }]; + } + return [.. result]; + + } + + /// + /// Gets the user token for a particular connection. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional code. + /// The cancellation token. + /// + public virtual async Task GetTokenAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "connectionName", connectionName }, + { "channelId", channelId } + }; + + if (!string.IsNullOrEmpty(code)) + { + queryParams.Add("code", code); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/GetToken"); + return await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/usertoken/GetToken", + queryParams, + body: null, + CreateRequestOptions("getting token", returnNullOnNotFound: true), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the token or raw signin link to be sent to the user for signin for a connection. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional final redirect URL. + /// The cancellation token. + /// + public virtual async Task GetSignInResource(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default) + { + var tokenExchangeState = new + { + ConnectionName = connectionName, + Conversation = new + { + User = new ConversationAccount { Id = userId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState, _defaultOptions); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); + + Dictionary queryParams = new() + { + { "state", state } + }; + + if (!string.IsNullOrEmpty(finalRedirect)) + { + queryParams.Add("finalRedirect", finalRedirect); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/botsignin/GetSignInResource"); + return (await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/botsignin/GetSignInResource", + queryParams, + body: null, + CreateRequestOptions("getting sign-in resource"), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Exchanges a token for another token. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The token to exchange. + /// The cancellation token. + public virtual async Task ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId }, + { "connectionName", connectionName }, + { "channelId", channelId } + }; + + var tokenExchangeRequest = new + { + token = exchangeToken + }; + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/exchange"); + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + _apiEndpoint, + "api/usertoken/exchange", + queryParams, + JsonSerializer.Serialize(tokenExchangeRequest), + CreateRequestOptions("exchanging token"), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Signs the user out of a connection, revoking their OAuth token. + /// + /// The unique identifier of the user to sign out. Cannot be null or empty. + /// Optional name of the OAuth connection to sign out from. If null, signs out from all connections. + /// Optional channel identifier. If provided, limits sign-out to tokens for this channel. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous sign-out operation. + public virtual async Task SignOutUserAsync(string userId, string? connectionName = null, string? channelId = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() + { + { "userid", userId } + }; + + if (!string.IsNullOrEmpty(connectionName)) + { + queryParams.Add("connectionName", connectionName); + } + + if (!string.IsNullOrEmpty(channelId)) + { + queryParams.Add("channelId", channelId); + } + + _logger.LogInformation("Calling API endpoint: {Endpoint}", "api/usertoken/SignOut"); + await _botHttpClient.SendAsync( + HttpMethod.Delete, + _apiEndpoint, + "api/usertoken/SignOut", + queryParams, + body: null, + CreateRequestOptions("signing out user"), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets AAD tokens for a user. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The resource URLs. + /// The cancellation token. + /// + public virtual async Task> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls = null, CancellationToken cancellationToken = default) + { + var body = new + { + channelId, + connectionName, + userId, + resourceUrls = resourceUrls ?? [] + }; + + _logger.LogInformation("Calling API endpoint with POST: {Endpoint}", "api/usertoken/GetAadTokens"); + return (await _botHttpClient.SendAsync>( + HttpMethod.Post, + _apiEndpoint, + "api/usertoken/GetAadTokens", + queryParams: null, + JsonSerializer.Serialize(body), + CreateRequestOptions("getting AAD tokens"), + cancellationToken).ConfigureAwait(false))!; + } + + private BotRequestOptions CreateRequestOptions(string operationDescription, bool returnNullOnNotFound = false) => + new() + { + AgenticIdentity = AgenticIdentity, + OperationDescription = operationDescription, + ReturnNullOnNotFound = returnNullOnNotFound + }; +} diff --git a/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj b/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj new file mode 100644 index 00000000..f0e2c1c8 --- /dev/null +++ b/core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/core/test/ABSTokenServiceClient/Program.cs b/core/test/ABSTokenServiceClient/Program.cs new file mode 100644 index 00000000..98de782d --- /dev/null +++ b/core/test/ABSTokenServiceClient/Program.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ABSTokenServiceClient; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Core.Hosting; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddUserTokenClient(); +builder.Services.AddHostedService(); +WebApplication host = builder.Build(); +host.Run(); diff --git a/core/test/ABSTokenServiceClient/UserTokenCLIService.cs b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs new file mode 100644 index 00000000..4271915f --- /dev/null +++ b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core; + +namespace ABSTokenServiceClient +{ + internal class UserTokenCLIService(UserTokenClient userTokenClient, ILogger logger) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + return ExecuteAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + protected async Task ExecuteAsync(CancellationToken cancellationToken) + { + const string userId = "your-user-id"; + const string connectionName = "graph"; + const string channelId = "msteams"; + + logger.LogInformation("Application started"); + + try + { + logger.LogInformation("=== Testing GetTokenStatus ==="); + GetTokenStatusResult[] tokenStatus = await userTokenClient.GetTokenStatusAsync(userId, channelId, null, cancellationToken); + logger.LogInformation("GetTokenStatus result: {Result}", JsonSerializer.Serialize(tokenStatus, new JsonSerializerOptions { WriteIndented = true })); + + if (tokenStatus[0].HasToken == true) + { + GetTokenResult? tokenResponse = await userTokenClient.GetTokenAsync(userId, connectionName, channelId, null, cancellationToken); + logger.LogInformation("GetToken result: {Result}", JsonSerializer.Serialize(tokenResponse, new JsonSerializerOptions { WriteIndented = true })); + } + else + { + GetSignInResourceResult req = await userTokenClient.GetSignInResource(userId, connectionName, channelId, null, cancellationToken); + logger.LogInformation("GetSignInResource result: {Result}", JsonSerializer.Serialize(req, new JsonSerializerOptions { WriteIndented = true })); + + Console.WriteLine("Code?"); + string code = Console.ReadLine()!; + + GetTokenResult? tokenResponse2 = await userTokenClient.GetTokenAsync(userId, connectionName, channelId, code, cancellationToken); + logger.LogInformation("GetToken With Code result: {Result}", JsonSerializer.Serialize(tokenResponse2, new JsonSerializerOptions { WriteIndented = true })); + } + + Console.WriteLine("Want to signout? y/n"); + string yn = Console.ReadLine()!; + if ("y".Equals(yn, StringComparison.OrdinalIgnoreCase)) + { + try + { + await userTokenClient.SignOutUserAsync(userId, connectionName, channelId, cancellationToken); + logger.LogInformation("SignOutUser completed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during SignOutUser"); + } + } + } + catch (Exception ex) + { + + logger.LogError(ex, "Error during API testing"); + } + + logger.LogInformation("Application completed successfully"); + } + } +} diff --git a/core/test/ABSTokenServiceClient/appsettings.json b/core/test/ABSTokenServiceClient/appsettings.json new file mode 100644 index 00000000..3c9252dc --- /dev/null +++ b/core/test/ABSTokenServiceClient/appsettings.json @@ -0,0 +1,28 @@ + +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Program": "Information", + "ABSTokenServiceClient.UserTokenCLIService": "Information" + } + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "HH:mm:ss:ms " + } + }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id-here", + "ClientId": "your-client-id-here", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "your-client-secret-here" + } + ] + } +} diff --git a/core/test/IntegrationTests.slnx b/core/test/IntegrationTests.slnx new file mode 100644 index 00000000..d3811a8d --- /dev/null +++ b/core/test/IntegrationTests.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ActivitiesTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ActivitiesTests.cs new file mode 100644 index 00000000..6d758f2b --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/ActivitiesTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +/// +/// Tests for simple activity types. +/// +public class ActivitiesTests +{ + [Fact] + public void MessageReaction_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageReaction + }; + coreActivity.Properties["reactionsAdded"] = System.Text.Json.JsonSerializer.SerializeToElement(new[] + { + new { type = "like" }, + new { type = "heart" } + }); + + MessageReactionActivity activity = MessageReactionActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.MessageReaction, activity.Type); + Assert.NotNull(activity.ReactionsAdded); + Assert.Equal(2, activity.ReactionsAdded!.Count); + } + + [Fact] + public void MessageDelete_Constructor_Default_SetsMessageDeleteType() + { + MessageDeleteActivity activity = new(); + Assert.Equal(TeamsActivityType.MessageDelete, activity.Type); + } + + [Fact] + public void MessageDelete_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageDelete, + Id = "deleted-msg-id" + }; + + MessageDeleteActivity messageDelete = MessageDeleteActivity.FromActivity(coreActivity); + Assert.NotNull(messageDelete); + Assert.Equal(TeamsActivityType.MessageDelete, messageDelete.Type); + Assert.Equal("deleted-msg-id", messageDelete.Id); + } + + [Fact] + public void MessageUpdate_Constructor_Default_SetsMessageUpdateType() + { + MessageUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.MessageUpdate, activity.Type); + } + + [Fact] + public void MessageUpdate_Constructor_WithText_SetsTextAndMessageUpdateType() + { + MessageUpdateActivity activity = new("Updated text"); + Assert.Equal(TeamsActivityType.MessageUpdate, activity.Type); + Assert.Equal("Updated text", activity.Text); + } + + [Fact] + public void MessageUpdate_InheritsFromMessageActivity() + { + MessageUpdateActivity activity = new() + { + Text = "Updated", + TextFormat = TextFormats.Markdown + }; + + Assert.Equal("Updated", activity.Text); + //Assert.Equal(InputHints.AcceptingInput, activity.InputHint); + Assert.Equal(TextFormats.Markdown, activity.TextFormat); + } + + [Fact] + public void MessageUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageUpdate + }; + coreActivity.Properties["text"] = "Test message"; + + MessageUpdateActivity messageUpdate = MessageUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(messageUpdate); + Assert.Equal(TeamsActivityType.MessageUpdate, messageUpdate.Type); + Assert.Equal("Test message", messageUpdate.Text); + } + + [Fact] + public void ConversationUpdate_Constructor_Default_SetsConversationUpdateType() + { + ConversationUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.ConversationUpdate, activity.Type); + } + + [Fact] + public void ConversationUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.ConversationUpdate + }; + //coreActivity.Properties["topicName"] = "Converted Topic"; + + ConversationUpdateActivity activity = ConversationUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.ConversationUpdate, activity.Type); + //Assert.Equal("Converted Topic", activity.TopicName); + } + + [Fact] + public void InstallUpdate_Constructor_Default_SetsInstallationUpdateType() + { + InstallUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.InstallationUpdate, activity.Type); + } + + [Fact] + public void InstallUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.InstallationUpdate + }; + coreActivity.Properties["action"] = "remove"; + + InstallUpdateActivity activity = InstallUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.InstallationUpdate, activity.Type); + Assert.Equal("remove", activity.Action); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/CitationEntityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/CitationEntityTests.cs new file mode 100644 index 00000000..d83ac00c --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/CitationEntityTests.cs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class CitationEntityTests +{ + [Fact] + public void AddCitation_CreatesEntityWithClaim() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Test Document", + Abstract = "Test abstract content" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(citation.Citation); + Assert.Single(citation.Citation); + Assert.Equal(1, citation.Citation[0].Position); + Assert.Equal("Test Document", citation.Citation[0].Appearance.Name); + Assert.Equal("Test abstract content", citation.Citation[0].Appearance.Abstract); + } + + [Fact] + public void AddCitation_MultipleCitations_AccumulateOnSameEntity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddCitation(1, new CitationAppearance + { + Name = "Document One", + Abstract = "First abstract" + }); + + var citation = activity.AddCitation(2, new CitationAppearance + { + Name = "Document Two", + Abstract = "Second abstract" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.NotNull(citation.Citation); + Assert.Equal(2, citation.Citation.Count); + Assert.Equal(1, citation.Citation[0].Position); + Assert.Equal(2, citation.Citation[1].Position); + Assert.Equal("Document One", citation.Citation[0].Appearance.Name); + Assert.Equal("Document Two", citation.Citation[1].Appearance.Name); + } + + [Fact] + public void AddAIGenerated_SetsAdditionalType() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var messageEntity = activity.AddAIGenerated(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(messageEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", messageEntity.AdditionalType); + } + + [Fact] + public void AddAIGenerated_CalledTwice_DoesNotDuplicate() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddAIGenerated(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + var messageEntity = activity.Entities[0] as OMessageEntity; + Assert.NotNull(messageEntity?.AdditionalType); + Assert.Single(messageEntity.AdditionalType); + } + + [Fact] + public void AddAIGenerated_ThenAddCitation_PreservesAILabel() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Test Doc", + Abstract = "Test abstract" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(citation.AdditionalType); + Assert.Contains("AIGeneratedContent", citation.AdditionalType); + Assert.NotNull(citation.Citation); + Assert.Single(citation.Citation); + } + + [Fact] + public void AddFeedback_SetsFeedbackLoopEnabled() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddFeedback(); + + Assert.NotNull(activity.ChannelData); + Assert.True(activity.ChannelData.FeedbackLoopEnabled); + } + + [Fact] + public void AddCitation_WithAllAppearanceFields_SetsCorrectly() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Full Document", + Abstract = "Full abstract", + Text = "{\"type\":\"AdaptiveCard\"}", + Url = new Uri("https://example.com/doc"), + EncodingFormat = EncodingFormats.AdaptiveCard, + Icon = CitationIcon.MicrosoftWord, + Keywords = ["keyword1", "keyword2"], + UsageInfo = new SensitiveUsageEntity { Name = "Confidential" } + }); + + Assert.NotNull(citation.Citation); + var appearance = citation.Citation[0].Appearance; + Assert.Equal("Full Document", appearance.Name); + Assert.Equal("Full abstract", appearance.Abstract); + Assert.Equal("{\"type\":\"AdaptiveCard\"}", appearance.Text); + Assert.Equal(new Uri("https://example.com/doc"), appearance.Url); + Assert.Equal(EncodingFormats.AdaptiveCard, appearance.EncodingFormat); + Assert.NotNull(appearance.Image); + Assert.Equal(CitationIcon.MicrosoftWord, appearance.Image.Name); + Assert.NotNull(appearance.Keywords); + Assert.Equal(2, appearance.Keywords.Count); + Assert.NotNull(appearance.UsageInfo); + Assert.Equal("Confidential", appearance.UsageInfo.Name); + } + + [Fact] + public void CitationEntity_RoundTrip_Serialization() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddCitation(1, new CitationAppearance + { + Name = "Test Document", + Abstract = "Test abstract content", + Url = new Uri("https://example.com"), + Icon = CitationIcon.Pdf, + Keywords = ["test", "citation"] + }); + activity.AddFeedback(); + + string json = activity.ToJson(); + + Assert.Contains("\"citation\"", json); + Assert.Contains("Test Document", json); + Assert.Contains("Test abstract content", json); + Assert.Contains("https://example.com", json); + Assert.Contains("AIGeneratedContent", json); + Assert.Contains("Claim", json); + Assert.Contains("DigitalDocument", json); + Assert.Contains("PDF", json); + Assert.Contains("feedbackLoopEnabled", json); + } + + [Fact] + public void CitationEntity_Rebase_SurvivesRoundTrip() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddCitation(1, new CitationAppearance + { + Name = "Rebase Test Doc", + Abstract = "Rebase test abstract", + Icon = CitationIcon.MicrosoftExcel + }); + + // Verify base CoreActivity.Entities (JsonArray) contains the citation data + CoreActivity coreActivity = activity; + Assert.NotNull(coreActivity.Entities); + Assert.Single(coreActivity.Entities); + + string? entityJson = coreActivity.Entities[0]?.ToJsonString(); + Assert.NotNull(entityJson); + Assert.Contains("citation", entityJson); + Assert.Contains("Rebase Test Doc", entityJson); + Assert.Contains("Rebase test abstract", entityJson); + Assert.Contains("AIGeneratedContent", entityJson); + Assert.Contains("Microsoft Excel", entityJson); + } + + [Fact] + public void Fixture_AdaptiveCardActivity_DeserializesAIGeneratedEntity() + { + string json = """ + { + "type": "message", + "channelId": "msteams", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0]; + Assert.Equal("https://schema.org/Message", entity.Type); + Assert.Equal("Message", entity.OType); + + // Should deserialize as CitationEntity (since @type is "Message") + var citationEntity = entity as CitationEntity; + Assert.NotNull(citationEntity); + Assert.NotNull(citationEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", citationEntity.AdditionalType); + } + + [Fact] + public void Fixture_SensitiveUsageEntity_DeserializesByOType() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "CreativeWork", + "name": "Confidential", + "description": "This is sensitive content" + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0] as SensitiveUsageEntity; + Assert.NotNull(entity); + Assert.Equal("Confidential", entity.Name); + Assert.Equal("This is sensitive content", entity.Description); + } + + [Fact] + public void OMessageEntity_WithUnknownOType_DeserializesAsOMessageEntity() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "UnknownType" + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0]; + Assert.IsType(entity); + Assert.Equal("UnknownType", entity.OType); + } + + [Fact] + public void Fixture_CitationEntity_DeserializesWithClaims() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": ["AIGeneratedContent"], + "citation": [ + { + "@type": "Claim", + "position": 1, + "appearance": { + "@type": "DigitalDocument", + "name": "Test Document", + "abstract": "Test abstract", + "url": "https://example.com/doc", + "encodingFormat": "application/vnd.microsoft.card.adaptive" + } + } + ] + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var citationEntity = activity.Entities[0] as CitationEntity; + Assert.NotNull(citationEntity); + Assert.NotNull(citationEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", citationEntity.AdditionalType); + Assert.NotNull(citationEntity.Citation); + Assert.Single(citationEntity.Citation); + Assert.Equal(1, citationEntity.Citation[0].Position); + Assert.Equal("Test Document", citationEntity.Citation[0].Appearance.Name); + Assert.Equal("Test abstract", citationEntity.Citation[0].Appearance.Abstract); + Assert.Equal(EncodingFormats.AdaptiveCard, citationEntity.Citation[0].Appearance.EncodingFormat); + } + + [Fact] + public void CitationEntity_CopyConstructor_PreservesData() + { + var original = new CitationEntity(); + original.AdditionalType = ["AIGeneratedContent"]; + original.Citation = [ + new CitationClaim + { + Position = 1, + Appearance = new CitationAppearanceDocument + { + Name = "Doc", + Abstract = "Abstract" + } + } + ]; + + var copy = new CitationEntity(original); + + Assert.NotNull(copy.AdditionalType); + Assert.Contains("AIGeneratedContent", copy.AdditionalType); + Assert.NotNull(copy.Citation); + Assert.Single(copy.Citation); + Assert.Equal(1, copy.Citation[0].Position); + Assert.Equal("Doc", copy.Citation[0].Appearance.Name); + + // Ensure it's a deep copy (modifying copy doesn't affect original) + copy.AdditionalType.Add("NewType"); + Assert.Single(original.AdditionalType); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/InvokeActivityTest.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/InvokeActivityTest.cs new file mode 100644 index 00000000..2afc6d7c --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/InvokeActivityTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class InvokeActivityTest +{ + [Fact] + public void DefaultCtor() + { + InvokeActivity ia = new(); + Assert.NotNull(ia); + Assert.Equal(TeamsActivityType.Invoke, ia.Type); + Assert.Null(ia.Name); + Assert.Null(ia.Value); + // Assert.Null(ia.Conversation); + } + + [Fact] + public void FromCoreActivityWithValue() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.Invoke, + Value = JsonNode.Parse("{ \"key\": \"value\" }"), + Conversation = new Conversation { Id = "convId" }, + Properties = new ExtendedPropertiesDictionary + { + { "name", "testName" } + } + }; + InvokeActivity ia = InvokeActivity.FromActivity(coreActivity); + Assert.NotNull(ia); + Assert.Equal(TeamsActivityType.Invoke, ia.Type); + Assert.Equal("testName", ia.Name); + Assert.NotNull(ia.Value); + Assert.Equal("convId", ia.Conversation?.Id); + Assert.Equal("value", ia.Value?["key"]?.ToString()); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageActivityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageActivityTests.cs new file mode 100644 index 00000000..f0345f5a --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/MessageActivityTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class MessageActivityTests +{ + [Fact] + public void Constructor_Default_SetsMessageType() + { + MessageActivity activity = new(); + Assert.Equal(TeamsActivityType.Message, activity.Type); + } + + [Fact] + public void Constructor_WithText_SetsTextAndMessageType() + { + MessageActivity activity = new("Hello World"); + Assert.Equal(TeamsActivityType.Message, activity.Type); + Assert.Equal("Hello World", activity.Text); + } + + [Fact] + public void MessageActivity_FromCoreActivity_MapsAllProperties() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(jsonMessageWithAllProps); + MessageActivity messageActivity = MessageActivity.FromActivity(coreActivity); + + Assert.Equal("Hello World", messageActivity.Text); + //Assert.Equal("This is a summary", messageActivity.Summary); + Assert.Equal("plain", messageActivity.TextFormat); + //Assert.Equal(InputHints.AcceptingInput, messageActivity.InputHint); + //Assert.Equal(ImportanceLevels.High, messageActivity.Importance); + //Assert.Equal(DeliveryModes.Normal, messageActivity.DeliveryMode); + Assert.Equal("carousel", messageActivity.AttachmentLayout); + //Assert.NotNull(messageActivity.Expiration); + } + + [Fact] + public void MessageActivity_Serialize_ToJson() + { + MessageActivity activity = new("Hello World") + { + // Summary = "Test summary", + TextFormat = TextFormats.Markdown, + //InputHint = InputHints.ExpectingInput, + //Importance = ImportanceLevels.Urgent, + //DeliveryMode = DeliveryModes.Notification + }; + + string json = activity.ToJson(); + + Assert.Contains("Hello World", json); + //Assert.Contains("Test summary", json); + Assert.Contains("markdown", json); + //Assert.Contains("expectingInput", json); + //Assert.Contains("urgent", json); + //Assert.Contains("notification", json); + } + + /* + [Fact] + public void MessageActivity_WithSpeak_Serialize() + { + MessageActivity activity = new("Hello") + { + Speak = "Hello World" + }; + + string json = activity.ToJson(); + Assert.Contains("\"speak\":", json); + Assert.Contains("Hello World", json); + } + + [Fact] + public void MessageActivity_WithExpiration_Serialize() + { + string expirationDate = "2026-12-31T23:59:59Z"; + MessageActivity activity = new("Expiring message") + { + Expiration = expirationDate + }; + + string json = activity.ToJson(); + Assert.Contains("2026-12-31T23:59:59Z", json); + } + */ + + + [Fact] + public void MessageActivity_Constants_TextFormats() + { + MessageActivity activity = new("Test") + { + TextFormat = TextFormats.Plain + }; + Assert.Equal("plain", activity.TextFormat); + + activity.TextFormat = TextFormats.Markdown; + Assert.Equal("markdown", activity.TextFormat); + + activity.TextFormat = TextFormats.Xml; + Assert.Equal("xml", activity.TextFormat); + } + + [Fact] + public void MessageActivity_FromCoreActivity_WithMissingProperties_HandlesGracefully() + { + CoreActivity coreActivity = new(ActivityType.Message); + MessageActivity messageActivity = MessageActivity.FromActivity(coreActivity); + + Assert.Null(messageActivity.Text); + //Assert.Null(messageActivity.Speak); + //Assert.Null(messageActivity.InputHint); + //Assert.Null(messageActivity.Summary); + Assert.Null(messageActivity.TextFormat); + Assert.Null(messageActivity.AttachmentLayout); + //Assert.Null(messageActivity.Importance); + //Assert.Null(messageActivity.DeliveryMode); + //Assert.Null(messageActivity.Expiration); + } + + [Fact] + public void MessageActivity_SerializedAsCoreActivity_IncludesText() + { + MessageActivity messageActivity = new("Hello World") + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + CoreActivity coreActivity = messageActivity; + string json = coreActivity.ToJson(); + + Assert.Contains("Hello World", json); + Assert.Contains("\"text\"", json); + } + + private const string jsonMessageWithAllProps = """ + { + "type": "message", + "channelId": "msteams", + "text": "Hello World", + "speak": "Hello World", + "inputHint": "acceptingInput", + "summary": "This is a summary", + "textFormat": "plain", + "attachmentLayout": "carousel", + "importance": "high", + "deliveryMode": "normal", + "expiration": "2026-12-31T23:59:59Z", + "id": "1234567890", + "timestamp": "2026-01-21T12:00:00Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "from": { + "id": "user-123", + "name": "Test User" + }, + "conversation": { + "id": "conversation-123" + }, + "recipient": { + "id": "bot-123", + "name": "Test Bot" + } + } + """; +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj new file mode 100644 index 00000000..3c769976 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/Microsoft.Teams.Bot.Apps.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/RouterTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/RouterTests.cs new file mode 100644 index 00000000..f54cf45a --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/RouterTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Apps.Routing; +using Microsoft.Teams.Bot.Apps.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class RouterTests +{ + private static Route MakeRoute(string name) where TActivity : TeamsActivity + => new() { Name = name, Selector = _ => true }; + + // ==================== Duplicate name ==================== + + [Fact] + public void Register_DuplicateName_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute("Message")); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute("Message"))); + + Assert.Contains("Message", ex.Message); + } + + [Fact] + public void Register_UniqueNames_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute("Message/hello")); + router.Register(MakeRoute("Message/bye")); + + Assert.Equal(2, router.GetRoutes().Count); + } + + // ==================== Invoke conflict ==================== + + [Fact] + public void Register_CatchAllInvokeAfterSpecific_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.AdaptiveCardAction}")); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute(TeamsActivityType.Invoke))); + + Assert.Contains("catch-all", ex.Message); + } + + [Fact] + public void Register_SpecificInvokeAfterCatchAll_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.Invoke)); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskFetch}"))); + + Assert.Contains("invoke", ex.Message); + } + + [Fact] + public void Register_MultipleCatchAllInvokes_ThrowsDuplicateName() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.Invoke)); + + Assert.Throws(() + => router.Register(MakeRoute(TeamsActivityType.Invoke))); + } + + [Fact] + public void Register_MultipleSpecificInvokeHandlers_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.AdaptiveCardAction}")); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskFetch}")); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskSubmit}")); + + Assert.Equal(3, router.GetRoutes().Count); + } + + // ==================== Non-invoke catch-all + specific is allowed ==================== + + [Fact] + public void Register_ConversationUpdateCatchAllAndSpecific_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.ConversationUpdate)); + router.Register(MakeRoute($"{TeamsActivityType.ConversationUpdate}/membersAdded")); + + Assert.Equal(2, router.GetRoutes().Count); + } + + [Fact] + public void Register_InstallUpdateCatchAllAndSpecific_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.InstallationUpdate)); + router.Register(MakeRoute($"{TeamsActivityType.InstallationUpdate}/add")); + router.Register(MakeRoute($"{TeamsActivityType.InstallationUpdate}/remove")); + + Assert.Equal(3, router.GetRoutes().Count); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs new file mode 100644 index 00000000..afddaa53 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityBuilderTests.cs @@ -0,0 +1,855 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class TeamsActivityBuilderTests +{ + private readonly TeamsActivityBuilder builder; + public TeamsActivityBuilderTests() + { + builder = TeamsActivity.CreateBuilder(); + } + + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + TeamsActivity activity = TeamsActivity.CreateBuilder().Build(); + + Assert.NotNull(activity); + Assert.Null(activity.From); + Assert.Null(activity.Recipient); + Assert.Null(activity.Conversation); + } + + [Fact] + public void Constructor_WithExistingActivity_UsesProvidedActivity() + { + TeamsActivity existingActivity = new() + { + Id = "test-id" + }; + existingActivity.Properties["text"] = "existing text"; + + TeamsActivityBuilder taBuilder = TeamsActivity.CreateBuilder(existingActivity); + TeamsActivity activity = taBuilder.Build(); + + Assert.Equal("test-id", activity.Id); + Assert.Equal("existing text", activity.Properties["text"]); + } + + [Fact] + public void Constructor_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => TeamsActivity.CreateBuilder(null!)); + } + + [Fact] + public void WithId_SetsActivityId() + { + TeamsActivity activity = builder + .WithId("test-activity-id") + .Build(); + + Assert.Equal("test-activity-id", activity.Id); + } + + [Fact] + public void WithServiceUrl_SetsServiceUrl() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/teams/"); + + TeamsActivity activity = builder + .WithServiceUrl(serviceUrl) + .Build(); + + Assert.Equal(serviceUrl, activity.ServiceUrl); + } + + [Fact] + public void WithChannelId_SetsChannelId() + { + TeamsActivity activity = builder + .WithChannelId("msteams") + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + } + + [Fact] + public void WithType_SetsActivityType() + { + TeamsActivity activity = builder + .WithType(TeamsActivityType.Message) + .Build(); + + Assert.Equal(TeamsActivityType.Message, activity.Type); + } + + [Fact] + public void WithText_SetsTextContent() + { + TeamsActivity activity = builder + .WithText("Hello, World!") + .Build(); + + Assert.Equal("Hello, World!", activity.Properties["text"]); + } + + [Fact] + public void WithFrom_SetsSenderAccount() + { + TeamsConversationAccount? fromAccount = TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "sender-id", + Name = "Sender Name" + }); + + TeamsActivity activity = builder + .WithFrom(fromAccount) + .Build(); + + Assert.Equal("sender-id", activity.From?.Id); + Assert.Equal("Sender Name", activity.From?.Name); + } + + [Fact] + public void WithRecipient_SetsRecipientAccount() + { + TeamsConversationAccount? recipientAccount = TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient Name" + }); + Assert.NotNull(recipientAccount); + TeamsActivity activity = builder + .WithRecipient(recipientAccount) + .Build(); + + Assert.Equal("recipient-id", activity.Recipient?.Id); + Assert.Equal("Recipient Name", activity.Recipient?.Name); + } + + [Fact] + public void WithConversation_SetsConversationInfo() + { + Conversation baseConversation = new() + { + Id = "conversation-id" + }; + + Assert.NotNull(baseConversation); + baseConversation.Properties.Add("tenantId", "tenant-123"); + baseConversation.Properties.Add("conversationType", "channel"); + TeamsConversation? conversation = TeamsConversation.FromConversation(baseConversation); + + TeamsActivity activity = builder + .WithConversation(conversation) + .Build(); + + Assert.Equal("conversation-id", activity.Conversation?.Id); + Assert.Equal("tenant-123", activity.Conversation?.TenantId); + Assert.Equal("channel", activity.Conversation?.ConversationType); + } + + [Fact] + public void WithChannelData_SetsChannelData() + { + TeamsChannelData channelData = new() + { + TeamsChannelId = "19:channel-id@thread.tacv2", + TeamsTeamId = "19:team-id@thread.tacv2" + }; + + TeamsActivity activity = builder + .WithChannelData(channelData) + .Build(); + + Assert.NotNull(activity.ChannelData); + Assert.Equal("19:channel-id@thread.tacv2", activity.ChannelData?.TeamsChannelId); + Assert.Equal("19:team-id@thread.tacv2", activity.ChannelData?.TeamsTeamId); + } + + [Fact] + public void WithEntities_SetsEntitiesCollection() + { + EntityList entities = + [ + new ClientInfoEntity + { + Locale = "en-US", + Platform = "Web" + } + ]; + + TeamsActivity activity = builder + .WithEntities(entities) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + } + + [Fact] + public void WithAttachments_SetsAttachmentsCollection() + { + List attachments = + [ + new() { + ContentType = "application/json", + Name = "test-attachment" + } + ]; + + TeamsActivity activity = builder + .WithAttachments(attachments) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("application/json", activity.Attachments[0].ContentType); + Assert.Equal("test-attachment", activity.Attachments[0].Name); + } + + [Fact] + public void WithAttachment_SetsSingleAttachment() + { + TeamsAttachment attachment = new() + { + ContentType = "application/json", + Name = "single" + }; + + TeamsActivity activity = builder + .WithAttachment(attachment) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("single", activity.Attachments[0].Name); + } + + [Fact] + public void AddEntity_AddsEntityToCollection() + { + ClientInfoEntity entity = new() + { + Locale = "en-US", + Country = "US" + }; + + TeamsActivity activity = builder + .AddEntity(entity) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + } + + [Fact] + public void AddEntity_MultipleEntities_AddsAllToCollection() + { + TeamsActivity activity = builder + .AddEntity(new ClientInfoEntity { Locale = "en-US" }) + .AddEntity(new ProductInfoEntity { Id = "product-123" }) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities?.Count); + } + + [Fact] + public void AddAttachment_AddsAttachmentToCollection() + { + TeamsAttachment attachment = new() + { + ContentType = "text/html", + Name = "test.html" + }; + + TeamsActivity activity = builder + .AddAttachment(attachment) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("text/html", activity.Attachments[0].ContentType); + } + + [Fact] + public void AddAttachment_MultipleAttachments_AddsAllToCollection() + { + TeamsActivity activity = builder + .AddAttachment(new TeamsAttachment { ContentType = "text/html" }) + .AddAttachment(new TeamsAttachment { ContentType = "application/json" }) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Equal(2, activity.Attachments?.Count); + } + + [Fact] + public void AddAdaptiveCardAttachment_AddsAdaptiveCard() + { + var adaptiveCard = new { type = "AdaptiveCard", version = "1.2" }; + + TeamsActivity activity = builder + .AddAdaptiveCardAttachment(adaptiveCard) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("application/vnd.microsoft.card.adaptive", activity.Attachments[0].ContentType); + Assert.Same(adaptiveCard, activity.Attachments[0].Content); + } + + [Fact] + public void WithAdaptiveCardAttachment_ConfigureActionAppliesChanges() + { + var adaptiveCard = new { type = "AdaptiveCard" }; + + TeamsActivity activity = builder + .WithAdaptiveCardAttachment(adaptiveCard, b => b.WithName("feedback")) + .Build(); + + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + Assert.Equal("feedback", activity.Attachments[0].Name); + } + + [Fact] + public void AddAdaptiveCardAttachment_WithNullPayload_Throws() + { + Assert.Throws(() => builder.AddAdaptiveCardAttachment(null!)); + } + + [Fact] + public void AddMention_WithNullAccount_ThrowsArgumentNullException() + { + Assert.Throws(() => builder.AddMention(null!)); + } + + [Fact] + public void AddMention_WithAccountAndDefaultText_AddsMentionAndUpdatesText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + TeamsActivity activity = builder + .WithText("said hello") + .AddMention(account) + .Build(); + + Assert.Equal("John Doe said hello", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-123", mention.Mentioned?.Id); + Assert.Equal("John Doe", mention.Mentioned?.Name); + Assert.Equal("John Doe", mention.Text); + } + + [Fact] + public void AddMention_WithCustomText_UsesCustomText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + TeamsActivity activity = builder + .WithText("replied") + .AddMention(account, "CustomName") + .Build(); + + Assert.Equal("CustomName replied", activity.Properties["text"]); + + MentionEntity? mention = activity.Entities![0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("CustomName", mention.Text); + } + + [Fact] + public void AddMention_WithAddTextFalse_DoesNotUpdateText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "John Doe" + }; + + TeamsActivity activity = builder + .WithText("original text") + .AddMention(account, addText: false) + .Build(); + + Assert.Equal("original text", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + } + + [Fact] + public void AddMention_MultipleMentions_AddsAllMentions() + { + ConversationAccount account1 = new() { Id = "user-1", Name = "User One" }; + ConversationAccount account2 = new() { Id = "user-2", Name = "User Two" }; + + TeamsActivity activity = builder + .WithText("message") + .AddMention(account1) + .AddMention(account2) + .Build(); + + Assert.Equal("User Two User One message", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities?.Count); + } + + [Fact] + public void FluentAPI_CompleteActivity_BuildsCorrectly() + { + TeamsActivity activity = builder + .WithType(TeamsActivityType.Message) + .WithId("activity-123") + .WithChannelId("msteams") + .WithText("Test message") + .WithServiceUrl(new Uri("https://smba.trafficmanager.net/teams/")) + .WithFrom(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "sender-id", + Name = "Sender" + })) + .WithRecipient(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient" + })) + .WithConversation(TeamsConversation.FromConversation(new Conversation + { + Id = "conv-id" + })) + .AddEntity(new ClientInfoEntity { Locale = "en-US" }) + .AddAttachment(new TeamsAttachment { ContentType = "text/html" }) + .AddMention(new ConversationAccount { Id = "user-1", Name = "User" }) + .Build(); + + Assert.Equal(TeamsActivityType.Message, activity.Type); + Assert.Equal("activity-123", activity.Id); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("User Test message", activity.Properties["text"]); + Assert.Equal("sender-id", activity.From?.Id); + Assert.Equal("recipient-id", activity.Recipient?.Id); + Assert.Equal("conv-id", activity.Conversation?.Id); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities?.Count); // ClientInfo + Mention + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + } + + [Fact] + public void FluentAPI_MethodChaining_ReturnsBuilderInstance() + { + + TeamsActivityBuilder result1 = builder.WithId("id"); + TeamsActivityBuilder result2 = builder.WithText("text"); + TeamsActivityBuilder result3 = builder.WithType(TeamsActivityType.Message); + + Assert.Same(builder, result1); + Assert.Same(builder, result2); + Assert.Same(builder, result3); + } + + [Fact] + public void Build_CalledMultipleTimes_ReturnsSameInstance() + { + builder + .WithId("test-id"); + + TeamsActivity activity1 = builder.Build(); + TeamsActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + } + + [Fact] + public void Builder_ModifyingExistingActivity_PreservesOriginalData() + { + TeamsActivity original = new() + { + Id = "original-id", + Type = TeamsActivityType.Message + }; + original.Properties["text"] = "original text"; + + TeamsActivity modified = TeamsActivity.CreateBuilder(original) + .WithText("modified text") + .Build(); + + Assert.Equal("original-id", modified.Id); + Assert.Equal("modified text", modified.Properties["text"]); + Assert.Equal(TeamsActivityType.Message, modified.Type); + } + + [Fact] + public void AddMention_UpdatesBaseEntityCollection() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "Test User" + }; + + TeamsActivity activity = builder + .AddMention(account) + .Build(); + + CoreActivity baseActivity = activity; + Assert.NotNull(baseActivity.Entities); + Assert.NotEmpty(baseActivity.Entities); + } + + [Fact] + public void WithChannelData_NullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithChannelData(null!) + .Build(); + + Assert.Null(activity.ChannelData); + } + + [Fact] + public void AddEntity_NullEntitiesCollection_InitializesCollection() + { + TeamsActivity activity = builder.Build(); + + Assert.Null(activity.Entities); + + ClientInfoEntity entity = new() { Locale = "en-US" }; + builder.AddEntity(entity); + + TeamsActivity result = builder.Build(); + Assert.NotNull(result.Entities); + Assert.Single(result.Entities); + } + + [Fact] + public void AddAttachment_NullAttachmentsCollection_InitializesCollection() + { + TeamsActivity activity = builder.Build(); + + Assert.Null(activity.Attachments); + + TeamsAttachment attachment = new() { ContentType = "text/html" }; + builder.AddAttachment(attachment); + + TeamsActivity result = builder.Build(); + Assert.NotNull(result.Attachments); + Assert.Single(result.Attachments); + } + + [Fact] + public void Builder_EmptyText_AddMention_PrependsMention() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "User" + }; + + TeamsActivity activity = builder + .AddMention(account) + .Build(); + + Assert.Equal("User ", activity.Properties["text"]); + } + + [Fact] + public void WithConversationReference_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => builder.WithConversationReference(null!)); + } + + [Fact] + public void WithConversationReference_WithNullChannelId_ThrowsArgumentNullException() + { + + TeamsActivity sourceActivity = new() + { + ChannelId = null, + ServiceUrl = new Uri("https://test.com"), + Conversation = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()) + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullServiceUrl_ThrowsArgumentNullException() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = null, + Conversation = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()) + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithEmptyConversationId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "bot-1" }) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.Conversation); + } + + [Fact] + public void WithConversationReference_WithEmptyFromId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = TeamsConversation.FromConversation(new Conversation { Id = "conv-1" }), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "bot-1" }) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.From); + } + + [Fact] + public void WithConversationReference_WithEmptyRecipientId_DoesNotThrow() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = TeamsConversation.FromConversation(new Conversation { Id = "conv-1" }), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.Recipient); + } + + [Fact] + public void WithFrom_WithBaseConversationAccount_ConvertsToTeamsConversationAccount() + { + ConversationAccount baseAccount = new() + { + Id = "user-123", + Name = "User Name" + }; + + TeamsActivity activity = builder + .WithFrom(baseAccount) + .Build(); + + Assert.IsType(activity.From); + Assert.Equal("user-123", activity.From?.Id); + Assert.Equal("User Name", activity.From?.Name); + } + + [Fact] + public void WithRecipient_WithBaseConversationAccount_ConvertsToTeamsConversationAccount() + { + ConversationAccount baseAccount = new() + { + Id = "bot-123", + Name = "Bot Name" + }; + + TeamsActivity activity = builder + .WithRecipient(baseAccount) + .Build(); + + Assert.IsType(activity.Recipient); + Assert.Equal("bot-123", activity.Recipient?.Id); + Assert.Equal("Bot Name", activity.Recipient?.Name); + } + + [Fact] + public void WithConversation_WithBaseConversation_ConvertsToTeamsConversation() + { + Conversation baseConversation = new() + { + Id = "conv-123" + }; + + TeamsActivity activity = builder + .WithConversation(baseConversation) + .Build(); + + Assert.IsType(activity.Conversation); + Assert.Equal("conv-123", activity.Conversation?.Id); + } + + [Fact] + public void WithEntities_WithNullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithEntities([new ClientInfoEntity()]) + .WithEntities(null!) + .Build(); + + Assert.Null(activity.Entities); + } + + [Fact] + public void WithAttachments_WithNullValue_SetsToNull() + { + TeamsActivity activity = builder + .WithAttachments([new()]) + .WithAttachments(null!) + .Build(); + + Assert.Null(activity.Attachments); + } + + [Fact] + public void AddMention_WithAccountWithNullName_UsesNullText() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = null + }; + + TeamsActivity activity = builder + .WithText("message") + .AddMention(account) + .Build(); + + Assert.Equal(" message", activity.Properties["text"]); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + } + + [Fact] + public void Build_MultipleCalls_ReturnsRebasedActivity() + { + builder + .AddEntity(new ClientInfoEntity { Locale = "en-US" }); + + TeamsActivity activity1 = builder.Build(); + CoreActivity baseActivity1 = activity1; + Assert.NotNull(baseActivity1.Entities); + + builder.AddEntity(new ProductInfoEntity { Id = "prod-1" }); + TeamsActivity activity2 = builder.Build(); + CoreActivity baseActivity2 = activity2; + + Assert.Same(activity1, activity2); + Assert.NotNull(baseActivity2.Entities); + Assert.Equal(2, activity2.Entities!.Count); + } + + [Fact] + public void IntegrationTest_CreateComplexActivity() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/amer/test/"); + TeamsChannelData channelData = new() + { + TeamsChannelId = "19:channel@thread.tacv2", + TeamsTeamId = "19:team@thread.tacv2" + }; + + Conversation conv = new() + { + Id = "conv-001", + Properties = + { + { "tenantId", "tenant-001" }, + { "conversationType", "channel" } + } + }; + + TeamsConversation? tc = TeamsConversation.FromConversation(conv); + Assert.NotNull(tc); + + TeamsActivity activity = builder + .WithType(TeamsActivityType.Message) + .WithId("msg-001") + .WithServiceUrl(serviceUrl) + .WithChannelId("msteams") + .WithText("Please review this document") + .WithFrom(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "bot-id", + Name = "Bot" + })) + .WithRecipient(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "user-id", + Name = "User" + })) + .WithConversation(tc) + .WithChannelData(channelData) + .AddEntity(new ClientInfoEntity + { + Locale = "en-US", + Country = "US", + Platform = "Web" + }) + .AddAttachment(new TeamsAttachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Name = "adaptive-card.json" + }) + .AddMention(new ConversationAccount + { + Id = "manager-id", + Name = "Manager" + }, "Manager") + .Build(); + + // Verify all properties + Assert.Equal(TeamsActivityType.Message, activity.Type); + Assert.Equal("msg-001", activity.Id); + Assert.Equal(serviceUrl, activity.ServiceUrl); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("Manager Please review this document", activity.Properties["text"]); + Assert.Equal("bot-id", activity.From?.Id); + Assert.Equal("user-id", activity.Recipient?.Id); + Assert.Equal("conv-001", activity.Conversation?.Id); + Assert.Equal("tenant-001", activity.Conversation?.TenantId); + Assert.Equal("channel", activity.Conversation?.ConversationType); + Assert.NotNull(activity.ChannelData); + Assert.Equal("19:channel@thread.tacv2", activity.ChannelData?.TeamsChannelId); + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities?.Count); // ClientInfo + Mention + Assert.NotNull(activity.Attachments); + Assert.Single(activity.Attachments); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs new file mode 100644 index 00000000..1a3f6938 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/TeamsActivityTests.cs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class TeamsActivityTests +{ + [Fact] + public void DownCastTeamsActivity_To_CoreActivity() + { + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", activity.Conversation!.Id); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(activity); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", teamsActivity.Conversation!.Id); + + static void AssertCid(CoreActivity a) + { + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", a.Conversation!.Id); + } + AssertCid(teamsActivity); + + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity_FromBuilder() + { + + TeamsActivity teamsActivity = TeamsActivity + .CreateBuilder() + .WithConversation(new Conversation() { Id = "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856" }) + .Build(); + + static void AssertCid(CoreActivity a) + { + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", a.Conversation!.Id); + } + AssertCid(teamsActivity); + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity_WithoutRebase() + { + TeamsActivity teamsActivity = new() + { + Conversation = new TeamsConversation() + { + Id = "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856" + } + }; + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", teamsActivity.Conversation!.Id); + + static void AssertCid(CoreActivity a) + { + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", a.Conversation!.Id); + } + AssertCid(teamsActivity); + + } + + + [Fact] + public void AddMentionEntity_To_TeamsActivity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity + .AddMention(new ConversationAccount + { + Id = "user-id-01", + Name = "rido" + }, "ridotest"); + + + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.Equal("mention", activity.Entities[0].Type); + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-id-01", mention.Mentioned?.Id); + Assert.Equal("rido", mention.Mentioned?.Name); + Assert.Equal("ridotest", mention.Text); + + string jsonResult = activity.ToJson(); + Assert.Contains("user-id-01", jsonResult); + } + + [Fact] + public void AddMentionEntity_Serialize_From_CoreActivity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity.AddMention(new ConversationAccount + { + Id = "user-id-01", + Name = "rido" + }, "ridotest"); + + + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.Equal("mention", activity.Entities[0].Type); + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-id-01", mention.Mentioned?.Id); + Assert.Equal("rido", mention.Mentioned?.Name); + Assert.Equal("ridotest", mention.Text); + + static void SerializeAndAssert(CoreActivity a) + { + string json = a.ToJson(); + Assert.Contains("user-id-01", json); + } + + SerializeAndAssert(activity); + } + + + [Fact] + public void TeamsActivityBuilder_FluentAPI() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Hello World") + .WithChannelId("msteams") + .AddMention(new ConversationAccount + { + Id = "user-123", + Name = "TestUser" + }) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("TestUser Hello World", activity.Properties["text"]); + Assert.Equal("msteams", activity.ChannelId); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + MentionEntity? mention = activity.Entities[0] as MentionEntity; + Assert.NotNull(mention); + Assert.Equal("user-123", mention.Mentioned?.Id); + Assert.Equal("TestUser", mention.Mentioned?.Name); + } + + [Fact] + public void Serialize_TeamsActivity_WithEntities() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithText("Hello World") + .WithChannelId("msteams") + .Build(); + + activity.AddClientInfo("Web", "US", "America/Los_Angeles", "en-US"); + + string jsonResult = activity.ToJson(); + Assert.Contains("clientInfo", jsonResult); + Assert.Contains("Web", jsonResult); + Assert.Contains("Hello World", jsonResult); + } + + [Fact] + public void Deserialize_TeamsActivity_Invoke_WithValue() + { + //TeamsActivity activity = CoreActivity.FromJsonString(jsonInvoke); + TeamsActivity activity = TeamsActivity.FromActivity(CoreActivity.FromJsonString(jsonInvoke)); + Assert.NotNull(activity.Value); + string feedback = activity.Value?["action"]?["data"]?["feedback"]?.ToString()!; + Assert.Equal("test invokes", feedback); + } + + [Fact] + public void Serialize_Does_Not_Repeat_AAdObjectId() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "message", + "recipient": { + "id": "rec1", + "name": "recname", + "aadObjectId": "rec-aadId-1" + } + } + """); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(coreActivity); + string json = teamsActivity.ToJson(); + string[] found = json.Split("aadObjectId"); + Assert.Equal(1, found.Length - 1); // only one occurrence + } + + [Fact] + public void FromActivity_Overrides_Recipient() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "message", + "recipient": { + "id": "rec1", + "name": "recname", + "agenticUserId": "0d5eb8a3-1642-4e63-9ccc-a89aa461716c", + "agenticAppId": "3fc62d4f-b04e-4c71-878b-02a2fa395fe2", + "agenticAppBlueprintId": "24fff850-d7fb-4d32-a6e7-a1178874430e" + } + } + """); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(coreActivity); + Assert.Equal("rec1", teamsActivity.Recipient?.Id); + Assert.Equal("recname", teamsActivity.Recipient?.Name); + AgenticIdentity? agenticIdentity = AgenticIdentity.FromProperties(teamsActivity.Recipient?.Properties); + Assert.NotNull(agenticIdentity); + Assert.Equal("0d5eb8a3-1642-4e63-9ccc-a89aa461716c", agenticIdentity.AgenticUserId); + Assert.Equal("3fc62d4f-b04e-4c71-878b-02a2fa395fe2", agenticIdentity.AgenticAppId); + Assert.Equal("24fff850-d7fb-4d32-a6e7-a1178874430e", agenticIdentity.AgenticAppBlueprintId); + } + + [Fact] + public void FromActivity_ReturnsDerivedType_WhenRegistered() + { + CoreActivity coreActivity = new(ActivityType.Message); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.IsType(activity); + } + + [Fact] + public void FromActivity_ReturnsBaseType_WhenNotRegistered() + { + CoreActivity coreActivity = new("unknownType"); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.Equal(typeof(TeamsActivity), activity.GetType()); + Assert.Equal("unknownType", activity.Type); + } + + [Fact] + public void EmptyTeamsActivity() + { + string minActivityJson = """ + { + "type": "message" + } + """; + + TeamsActivity teamsActivity = TeamsActivity.CreateBuilder().Build(); + Assert.NotNull(teamsActivity); + string json = teamsActivity.ToJson(); + Assert.Equal(minActivityJson, json); + } + + [Fact] + public void BaseFieldsAsBaseTypes() + { + CoreActivity ca = new(); + ca.Conversation = new Conversation() { Id = "conv1" }; + ca.Conversation.Properties.Add("tenantId", "tenant-1"); + CoreActivity ta = TeamsActivity.FromActivity(ca); + if (ta.Conversation is not null) + { + Assert.NotNull(ta.Conversation); + Assert.Equal("conv1", ta.Conversation.Id); + Assert.Empty(ta.Conversation.Properties); + } + else + { + Assert.Fail("Conversation not set"); + } + } + + [Fact] + public void Deserialize_with_Conversation_and_Tenant() + { + string json = """ + { + "type" : "message", + "conversation": { + "id" : "conv1", + "tenantId" : "tenant-1" + } + } + """; + CoreActivity ca = CoreActivity.FromJsonString(json); + Assert.NotNull(ca); + Assert.NotNull(ca.Conversation); + Assert.Equal("conv1", ca.Conversation.Id); + if (ca.Conversation.Properties.TryGetValue("tenantId", out object? outTenantId)) + { + Assert.Equal("tenant-1", outTenantId?.ToString()); + } + else + { + Assert.Fail("conversation tenant not set"); + } + TeamsActivity ta = TeamsActivity.FromActivity(ca); + Assert.NotNull(ta); + Assert.NotNull(ta.Conversation); + Assert.Equal("conv1", ta.Conversation.Id); + Assert.Equal("tenant-1", ta.Conversation.TenantId); + } + + + private const string jsonInvoke = """ + { + "type": "invoke", + "channelId": "msteams", + "id": "f:17b96347-e8b4-f340-10bc-eb52fc1a6ad4", + "serviceUrl": "https://smba.trafficmanager.net/amer/56653e9d-2158-46ee-90d7-675c39642038/", + "channelData": { + "tenant": { + "id": "56653e9d-2158-46ee-90d7-675c39642038" + }, + "source": { + "name": "message" + }, + "legacy": { + "replyToId": "1:12SWreU4430kJA9eZCb1kXDuo6A8KdDEGB6d9TkjuDYM" + } + }, + "from": { + "id": "29:1uMVvhoAyfTqdMsyvHL0qlJTTfQF9MOUSI8_cQts2kdSWEZVDyJO2jz-CsNOhQcdYq1Bw4cHT0__O6XDj4AZ-Jw", + "name": "Rido", + "aadObjectId": "c5e99701-2a32-49c1-a660-4629ceeb8c61" + }, + "recipient": { + "id": "28:aabdbd62-bc97-4afb-83ee-575594577de5", + "name": "ridobotlocal" + }, + "conversation": { + "id": "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT", + "conversationType": "personal", + "tenantId": "56653e9d-2158-46ee-90d7-675c39642038" + }, + "entities": [ + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "value": { + "action": { + "type": "Action.Execute", + "title": "Submit Feedback", + "data": { + "feedback": "test invokes" + } + }, + "trigger": "manual" + }, + "name": "adaptiveCard/action", + "timestamp": "2026-01-07T06:04:59.89Z", + "localTimestamp": "2026-01-06T22:04:59.89-08:00", + "replyToId": "1767765488332", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; + + private const string json = """ + { + "type": "message", + "channelId": "msteams", + "text": "\u003Cat\u003Eridotest\u003C/at\u003E reply to thread", + "id": "1759944781430", + "serviceUrl": "https://smba.trafficmanager.net/amer/50612dbb-0237-4969-b378-8d42590f9c00/", + "channelData": { + "teamsChannelId": "19:6848757105754c8981c67612732d9aa7@thread.tacv2", + "teamsTeamId": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2", + "channel": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2" + }, + "team": { + "id": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2" + }, + "tenant": { + "id": "50612dbb-0237-4969-b378-8d42590f9c00" + } + }, + "from": { + "id": "29:17bUvCasIPKfQIXHvNzcPjD86fwm6GkWc1PvCGP2-NSkNb7AyGYpjQ7Xw-XgTwaHW5JxZ4KMNDxn1kcL8fwX1Nw", + "name": "rido", + "aadObjectId": "b15a9416-0ad3-4172-9210-7beb711d3f70" + }, + "recipient": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "conversation": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", + "isGroup": true, + "conversationType": "channel", + "tenantId": "50612dbb-0237-4969-b378-8d42590f9c00" + }, + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "textFormat": "plain", + "attachments": [ + { + "contentType": "text/html", + "content": "\u003Cp\u003E\u003Cspan itemtype=\u0022http://schema.skype.com/Mention\u0022 itemscope=\u0022\u0022 itemid=\u00220\u0022\u003Eridotest\u003C/span\u003E\u0026nbsp;reply to thread\u003C/p\u003E" + } + ], + "timestamp": "2025-10-08T17:33:01.4953744Z", + "localTimestamp": "2025-10-08T10:33:01.4953744-07:00", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; + + +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs new file mode 100644 index 00000000..06de29f3 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatActivityTests.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using AdaptiveCards; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Bot.Core.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Bot.Compat.UnitTests +{ + public class CompatActivityTests + { + #region Core Properties Tests + + [Fact] + public void FromCompatActivity_PreservesCoreProperties() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ServiceUrl = "https://smba.trafficmanager.net/teams", + ChannelId = "msteams", + Id = "test-id-123", + From = new ChannelAccount { Id = "user-123", Name = "Test User" }, + Recipient = new ChannelAccount { Id = "bot-456", Name = "Test Bot" }, + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conv-789", Name = "Test Conversation" } + }; + + CoreActivity coreActivity = activity.FromCompatActivity(); + + Assert.NotNull(coreActivity); + Assert.Equal(activity.Type, coreActivity.Type); + Assert.Equal(activity.ServiceUrl, coreActivity.ServiceUrl?.ToString()); + Assert.Equal(activity.ChannelId, coreActivity.ChannelId); + Assert.Equal(activity.Id, coreActivity.Id); + Assert.Equal(activity.From?.Id, coreActivity.From?.Id); + Assert.Equal(activity.From?.Name, coreActivity.From?.Name); + Assert.Equal(activity.Recipient?.Id, coreActivity.Recipient?.Id); + Assert.Equal(activity.Conversation?.Id, coreActivity.Conversation?.Id); + } + + [Fact] + public void FromCompatActivity_PreservesTextAndMetadata() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Hello, this is a test message", + TextFormat = "plain", + Locale = "en-US", + InputHint = "acceptingInput", + ReplyToId = "reply-to-123" + }; + + CoreActivity coreActivity = activity.FromCompatActivity(); + + Assert.NotNull(coreActivity); + Assert.Equal(activity.Text, coreActivity.Properties["text"]?.ToString()); + Assert.Equal(activity.InputHint, coreActivity.Properties["inputHint"]?.ToString()); + Assert.Equal(activity.ReplyToId, coreActivity.ReplyToId); + Assert.Equal(activity.Locale, coreActivity.Properties["locale"]?.ToString()); + } + + #endregion + + #region Attachments Tests + + [Fact] + public void FromCompatActivity_PreservesAdaptiveCardAttachment() + { + string json = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + Assert.NotNull(botActivity); + Assert.Single(botActivity.Attachments); + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + + Assert.NotNull(coreActivity); + Assert.NotNull(coreActivity.Attachments); + Assert.Single(coreActivity.Attachments); + + JsonNode? attachmentNode = coreActivity.Attachments[0]; + Assert.NotNull(attachmentNode); + JsonObject attachmentObj = attachmentNode.AsObject(); + + string? contentType = attachmentObj["contentType"]?.GetValue(); + Assert.Equal("application/vnd.microsoft.card.adaptive", contentType); + + JsonNode? content = attachmentObj["content"]; + Assert.NotNull(content); + AdaptiveCard card = AdaptiveCard.FromJson(content.ToJsonString()).Card; + Assert.Equal(2, card.Body?.Count); + AdaptiveTextBlock? firstTextBlock = card?.Body?[0] as AdaptiveTextBlock; + Assert.NotNull(firstTextBlock); + Assert.Equal("Mention a user by User Principle Name: Hello Test User UPN", firstTextBlock.Text); + } + + [Fact] + public void FromCompatActivity_PreservesMultipleAttachments() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + Attachments = new List + { + new() { ContentType = "text/plain", Content = "First attachment" }, + new() { ContentType = "image/png", ContentUrl = "https://example.com/image.png" } + } + }; + + CoreActivity coreActivity = activity.FromCompatActivity(); + + Assert.NotNull(coreActivity.Attachments); + Assert.Equal(2, coreActivity.Attachments?.Count); + Assert.Equal("text/plain", coreActivity.Attachments?[0]?["contentType"]?.GetValue()); + Assert.Equal("image/png", coreActivity.Attachments?[1]?["contentType"]?.GetValue()); + } + + #endregion + + #region Entities Tests + + [Fact] + public void FromCompatActivity_PreservesEntities() + { + string json = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + + Assert.NotNull(coreActivity.Entities); + Assert.Single(coreActivity.Entities); + + JsonObject? entity = coreActivity.Entities[0]?.AsObject(); + Assert.NotNull(entity); + Assert.Equal("https://schema.org/Message", entity["type"]?.GetValue()); + } + + [Fact] + public void FromCompatActivity_PreservesMultipleEntities() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + + Assert.NotNull(coreActivity.Entities); + Assert.Equal(2, coreActivity.Entities?.Count); + + JsonObject? firstEntity = coreActivity.Entities?[0]?.AsObject(); + Assert.Equal("https://schema.org/Message", firstEntity?["type"]?.GetValue()); + + JsonObject? secondEntity = coreActivity.Entities?[1]?.AsObject(); + Assert.Equal("BotMessageMetadata", secondEntity?["type"]?.GetValue()); + } + + #endregion + + #region SuggestedActions Tests + + [Fact] + public void FromCompatActivity_PreservesSuggestedActions() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + Assert.NotNull(botActivity.SuggestedActions); + Assert.Equal(3, botActivity.SuggestedActions.Actions.Count); + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + + Assert.True(coreActivity.Properties.ContainsKey("suggestedActions")); + + string coreActivityJson = coreActivity.ToJson(); + JsonNode coreActivityNode = JsonNode.Parse(coreActivityJson)!; + + JsonNode? suggestedActions = coreActivityNode["suggestedActions"]; + Assert.NotNull(suggestedActions); + + JsonArray? actions = suggestedActions["actions"]?.AsArray(); + Assert.NotNull(actions); + Assert.Equal(3, actions.Count); + } + + [Fact] + public void FromCompatActivity_PreservesSuggestedActionDetails() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + string coreActivityJson = coreActivity.ToJson(); + JsonNode coreActivityNode = JsonNode.Parse(coreActivityJson)!; + + JsonArray? actions = coreActivityNode["suggestedActions"]?["actions"]?.AsArray(); + Assert.NotNull(actions); + + // Verify Action.Odsl actions + Assert.Equal("Action.Odsl", actions[0]?["type"]?.GetValue()); + Assert.Equal("Add reviewers", actions[0]?["title"]?.GetValue()); + Assert.NotNull(actions[0]?["value"]); + + Assert.Equal("Action.Odsl", actions[1]?["type"]?.GetValue()); + Assert.Equal("Open agent settings", actions[1]?["title"]?.GetValue()); + + // Verify Action.Compose action + Assert.Equal("Action.Compose", actions[2]?["type"]?.GetValue()); + Assert.Equal("Ask me a question", actions[2]?["title"]?.GetValue()); + Assert.NotNull(actions[2]?["value"]); + } + + #endregion + + #region ChannelData Tests + + [Fact] + public void FromCompatActivity_PreservesChannelData() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ChannelData = new { customProperty = "customValue", nestedObject = new { key = "value" } } + }; + + CoreActivity coreActivity = activity.FromCompatActivity(); + + Assert.NotNull(coreActivity.ChannelData); + Assert.True(coreActivity.ChannelData.Properties.ContainsKey("customProperty")); + Assert.Equal("customValue", coreActivity.ChannelData.Properties["customProperty"]?.ToString()); + } + + [Fact] + public void FromCompatActivity_PreservesComplexChannelData() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + + Assert.NotNull(coreActivity.ChannelData); + Assert.True(coreActivity.ChannelData.Properties.ContainsKey("feedbackLoopEnabled")); + + JsonElement feedbackLoopValue = (JsonElement)coreActivity.ChannelData.Properties["feedbackLoopEnabled"]!; + Assert.True(feedbackLoopValue.GetBoolean()); + } + + #endregion + + #region Integration Tests + + [Fact] + public void FromCompatActivity_CompleteRoundTrip_AdaptiveCard() + { + // Verify the complete adaptive card payload round-trips successfully + string originalJson = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(originalJson)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + string coreActivityJson = coreActivity.ToJson(); + + // Use JsonNode.DeepEquals to verify structural equality + JsonNode originalNode = JsonNode.Parse(originalJson)!; + JsonNode coreNode = JsonNode.Parse(coreActivityJson)!; + + Assert.True(JsonNode.DeepEquals(originalNode, coreNode)); + } + + [Fact] + public void FromCompatActivity_CompleteRoundTrip_SuggestedActions() + { + // Verify the complete suggested actions payload round-trips successfully + string originalJson = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(originalJson)!; + + CoreActivity coreActivity = botActivity.FromCompatActivity(); + string coreActivityJson = coreActivity.ToJson(); + + // Use JsonNode.DeepEquals to verify structural equality + JsonNode originalNode = JsonNode.Parse(originalJson)!; + JsonNode coreNode = JsonNode.Parse(coreActivityJson)!; + + Assert.True(JsonNode.DeepEquals(originalNode, coreNode)); + } + + #endregion + + private static string LoadTestData(string fileName) + { + string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData", fileName); + return File.ReadAllText(testDataPath); + } + } +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs new file mode 100644 index 00000000..755cc1f6 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/CompatAdapterTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Core; +using Moq; + +namespace Microsoft.Teams.Bot.Compat.UnitTests +{ + public class CompatAdapterTests + { + [Fact] + public async Task ContinueConversationAsync_WhenCastToBotAdapter_BuildsTurnContextWithUnderlyingClients() + { + // Arrange + (CompatAdapter? compatAdapter, TeamsApiClient? teamsApiClient) = CreateCompatAdapter(); + + // Cast to BotAdapter to ensure we're using the base class method + BotAdapter botAdapter = compatAdapter; + + ConversationReference conversationReference = new() + { + ServiceUrl = "https://smba.trafficmanager.net/teams", + ChannelId = "msteams", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "test-conversation-id" } + }; + + bool callbackInvoked = false; + Microsoft.Bot.Connector.Authentication.UserTokenClient? capturedUserTokenClient = null; + Microsoft.Bot.Connector.IConnectorClient? capturedConnectorClient = null; + Microsoft.Teams.Bot.Apps.TeamsApiClient? capturedTeamsApiClient = null; + + BotCallbackHandler callback = async (turnContext, cancellationToken) => + { + callbackInvoked = true; + capturedUserTokenClient = turnContext.TurnState.Get(); + capturedConnectorClient = turnContext.TurnState.Get(); + capturedTeamsApiClient = turnContext.TurnState.Get(); + await Task.CompletedTask; + }; + + // Act + await botAdapter.ContinueConversationAsync( + "test-bot-id", + conversationReference, + callback, + CancellationToken.None); + + // Assert + Assert.True(callbackInvoked); + + // Verify UserTokenClient is CompatUserTokenClient (check by type name since it's internal) + Assert.NotNull(capturedUserTokenClient); + Assert.Equal("CompatUserTokenClient", capturedUserTokenClient.GetType().Name); + Assert.IsAssignableFrom(capturedUserTokenClient); + + // Verify ConnectorClient is CompatConnectorClient (check by type name since it's internal) + Assert.NotNull(capturedConnectorClient); + Assert.Equal("CompatConnectorClient", capturedConnectorClient.GetType().Name); + Assert.IsAssignableFrom(capturedConnectorClient); + + // Verify TeamsApiClient is the same instance we set up + Assert.NotNull(capturedTeamsApiClient); + Assert.Same(teamsApiClient, capturedTeamsApiClient); + } + + private static (CompatAdapter, TeamsApiClient) CreateCompatAdapter() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + Mock mockConfig = new(); + mockConfig.Setup(c => c["UserTokenApiEndpoint"]).Returns("https://token.botframework.com"); + + UserTokenClient userTokenClient = new(httpClient, mockConfig.Object, NullLogger.Instance); + TeamsApiClient teamsApiClient = new(httpClient, NullLogger.Instance); + + TeamsBotApplication teamsBotApplication = new( + conversationClient, + userTokenClient, + teamsApiClient, + Mock.Of(), + NullLogger.Instance); + + CompatAdapter compatAdapter = new( + teamsBotApplication, + Mock.Of(), + NullLogger.Instance); + + return (compatAdapter, teamsApiClient); + } + } +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj new file mode 100644 index 00000000..0d3d0f06 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/Microsoft.Teams.Bot.Compat.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/AdaptiveCardActivity.json b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/AdaptiveCardActivity.json new file mode 100644 index 00000000..0e825200 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/AdaptiveCardActivity.json @@ -0,0 +1,74 @@ +{ + "type": "message", + "serviceUrl": "https://smba.trafficmanager.net/amer/1a2b3c4d-5e6f-4789-a0b1-c2d3e4f5a6b7/", + "channelId": "msteams", + "from": { + "id": "28:b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + "name": "testbot-local" + }, + "conversation": { + "conversationType": "personal", + "id": "a:1AbCdEfGhIjKlMnOpQrStUvWxYz2AbCdEfGhIjKlMnOpQrStUvWxYz3AbCdEfGhIjKlMnOpQrStUvWxYz4AbCdEfGhIjKlMnOpQrStUvWxYz5AbCdEf", + "tenantId": "1a2b3c4d-5e6f-4789-a0b1-c2d3e4f5a6b7" + }, + "recipient": { + "id": "29:9xYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOpQrStUvWxYzAb", + "name": "Test User", + "aadObjectId": "7f8e9d0c-1b2a-4354-6758-9a0b1c2d3e4f" + }, + "attachmentLayout": "list", + "locale": "en-US", + "inputHint": "acceptingInput", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "speak": "This card mentions a user by User Principle Name: Hello Test User", + "body": [ + { + "type": "TextBlock", + "text": "Mention a user by User Principle Name: Hello Test User UPN" + }, + { + "type": "TextBlock", + "text": "Mention a user by AAD Object Id: Hello Test User AAD" + } + ], + "msteams": { + "entities": [ + { + "type": "mention", + "text": "Test User UPN", + "mentioned": { + "id": "testuser@example.onmicrosoft.com", + "name": "Test User" + } + }, + { + "type": "mention", + "text": "Test User AAD", + "mentioned": { + "id": "7f8e9d0c-1b2a-4354-6758-9a0b1c2d3e4f", + "name": "Test User" + } + } + ] + } + } + } + ], + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + } + ], + "replyToId": "f:a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7" +} diff --git a/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/SuggestedActionsActivity.json b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/SuggestedActionsActivity.json new file mode 100644 index 00000000..617c6616 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Compat.UnitTests/TestData/SuggestedActionsActivity.json @@ -0,0 +1,240 @@ +{ + "type": "message", + "serviceUrl": "https://smba.trafficmanager.net/teams", + "from": { + "id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" + }, + "recipient": {}, + "conversation": { + "id": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + "text": "Hi there.\n\nI'm working on a status report and will share it in this chat shortly. You'll be able to make edits, and once it's ready, send it to the channel.\n\n\n\nYou can add more reviewers anytime.", + "inputHint": "acceptingInput", + "suggestedActions": { + "actions": [ + { + "type": "Action.Odsl", + "title": "Add reviewers", + "value": { + "actions": { + "odsl": { + "statements": [ + { + "name": "statusReportConfiguration", + "arguments": [ + { + "name": "agentId", + "value": "" + }, + { + "name": "agentName", + "value": "PA Test" + }, + { + "name": "agentType", + "value": 0 + }, + { + "name": "teamId", + "value": "" + }, + { + "name": "channelId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "recurrence", + "value": { + "pattern": { + "patternType": "test" + }, + "range": { + "startDate": "test", + "endDate": "test" + } + } + }, + { + "name": "approvalList", + "value": [ + "123", + "345" + ] + }, + { + "name": "welcomeMessageType", + "value": "ApproversChat" + }, + { + "name": "chatId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "displayText", + "value": "Add reviewers" + }, + { + "name": "deleteAgentDisplayText", + "value": "" + } + ] + } + ] + } + }, + "entities": [ + "chat" + ] + } + }, + { + "type": "Action.Odsl", + "title": "Open agent settings", + "value": { + "actions": { + "odsl": { + "statements": [ + { + "name": "agentConfiguration", + "arguments": [ + { + "name": "agentId", + "value": "" + }, + { + "name": "agentName", + "value": "PA Test" + }, + { + "name": "agentType", + "value": 0 + }, + { + "name": "teamId", + "value": "" + }, + { + "name": "channelId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "recurrence", + "value": { + "pattern": { + "patternType": "test" + }, + "range": { + "startDate": "test", + "endDate": "test" + } + } + }, + { + "name": "approvalList", + "value": [ + "123", + "345" + ] + }, + { + "name": "welcomeMessageType", + "value": "ApproversChat" + }, + { + "name": "chatId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "displayText", + "value": "Open agent settings" + }, + { + "name": "deleteAgentDisplayText", + "value": "" + } + ] + } + ] + } + }, + "entities": [ + "chat" + ] + } + }, + { + "type": "Action.Compose", + "title": "Ask me a question", + "value": { + "type": "Teams.chatMessage", + "data": { + "body": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": true + }, + "content": "PA Test" + }, + "mentions": [ + { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "id": 0, + "mentioned": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "odataType": "#microsoft.graph.chatMessageMentionedIdentitySet", + "user": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "displayName": "PA Test", + "id": "28:a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6", + "appId": "" + } + }, + "mentionText": "PA Test" + } + ], + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": true + } + } + } + } + ] + }, + "attachments": [], + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + }, + { + "type": "BotMessageMetadata", + "botTelemetryMessageType": "Welcome-AddApproverChat", + "aiMetadata": { + "botAiSkill": "{\"cv\":\"GsaulSWnUUWlHf97qANDWA.0.0\",\"reasoningActive\":false}" + } + } + ], + "channelData": { + "feedbackLoopEnabled": true + }, + "replyToId": "f7e8d9c0-b1a2-4536-9271-a8b9c0d1e2f3" +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs new file mode 100644 index 00000000..d78d74f3 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatConversationClientTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Bot.Core.Tests +{ + public class CompatConversationClientTests + { + string serviceUrl = "https://smba.trafficmanager.net/amer/"; + + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + [Fact(Skip = "not implemented")] + public async Task GetMemberAsync() + { + + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + TeamsChannelAccount member = await TeamsInfo.GetMemberAsync(turnContext, userId, cancellationToken: cancellationToken); + Assert.NotNull(member); + Assert.Equal(userId, member.Id); + + }, CancellationToken.None); + } + + [Fact] + public async Task GetPagedMembersAsync() + { + + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + var result = await TeamsInfo.GetPagedMembersAsync(turnContext, cancellationToken: cancellationToken); + Assert.NotNull(result); + Assert.True(result.Members.Count > 0); + var m0 = result.Members[0]; + Assert.Equal(userId, m0.Id); + + }, CancellationToken.None); + } + + [Fact(Skip = "not implemented")] + public async Task GetMeetingInfo() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + var compatAdapter = InitializeCompatAdapter(); + ConversationReference conversationReference = new ConversationReference + + { + ChannelId = "msteams", + ServiceUrl = serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + + await compatAdapter.ContinueConversationAsync( + string.Empty, conversationReference, + async (turnContext, cancellationToken) => + { + var result = await TeamsInfo.GetMeetingInfoAsync(turnContext, meetingId, cancellationToken); + Assert.NotNull(result); + + }, CancellationToken.None); + } + + + CompatAdapter InitializeCompatAdapter() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(configuration); + services.AddCompatAdapter(); + services.AddLogging(configure => configure.AddConsole()); + + var serviceProvider = services.BuildServiceProvider(); + CompatAdapter compatAdapter = (CompatAdapter)serviceProvider.GetRequiredService(); + return compatAdapter; + } + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs new file mode 100644 index 00000000..0546c7da --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/CompatTeamsInfoTests.cs @@ -0,0 +1,591 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Compat; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Bot.Core.Tests +{ + /// + /// Integration tests for CompatTeamsInfo static methods. + /// These tests verify that the compatibility layer correctly adapts + /// Bot Framework TeamsInfo API to Teams Bot Core SDK. + /// + public class CompatTeamsInfoTests + { + private readonly string _serviceUrl = "https://smba.trafficmanager.net/amer/"; + private readonly string _userId; + private readonly string _conversationId; + private readonly string _teamId; + private readonly string _channelId; + private readonly string _meetingId; + private readonly string _tenantId; + + public CompatTeamsInfoTests() + { + // These tests require environment variables for live integration testing + _userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? "29:test-user-id"; + _conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? "19:test-conversation-id"; + _teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? "19:test-team-id"; + _channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? "19:test-channel-id"; + _meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? "test-meeting-id"; + _tenantId = Environment.GetEnvironmentVariable("TEST_TENANTID") ?? "test-tenant-id"; + } + + [Fact] + public async Task GetMemberAsync_WithValidUserId_ReturnsMember() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + TeamsChannelAccount member = await CompatTeamsInfo.GetMemberAsync( + turnContext, + _userId, + cancellationToken); + + Assert.NotNull(member); + Assert.Equal(_userId, member.Id); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetMembersAsync_ReturnsMembers() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { +#pragma warning disable CS0618 // Type or member is obsolete + var members = await CompatTeamsInfo.GetMembersAsync(turnContext, cancellationToken); +#pragma warning restore CS0618 // Type or member is obsolete + + Assert.NotNull(members); + Assert.NotEmpty(members); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetPagedMembersAsync_ReturnsPagedResult() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var result = await CompatTeamsInfo.GetPagedMembersAsync( + turnContext, + pageSize: 10, + cancellationToken: cancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.True(result.Members.Count > 0); + + var firstMember = result.Members[0]; + Assert.NotNull(firstMember.Id); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetTeamMemberAsync_WithValidUserId_ReturnsMember() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var member = await CompatTeamsInfo.GetTeamMemberAsync( + turnContext, + _userId, + _teamId, + cancellationToken); + + Assert.NotNull(member); + Assert.Equal(_userId, member.Id); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetTeamMembersAsync_ReturnsTeamMembers() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { +#pragma warning disable CS0618 // Type or member is obsolete + var members = await CompatTeamsInfo.GetTeamMembersAsync( + turnContext, + _teamId, + cancellationToken); +#pragma warning restore CS0618 // Type or member is obsolete + + Assert.NotNull(members); + Assert.NotEmpty(members); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetPagedTeamMembersAsync_ReturnsPagedResult() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var result = await CompatTeamsInfo.GetPagedTeamMembersAsync( + turnContext, + _teamId, + pageSize: 5, + cancellationToken: cancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetMeetingInfoAsync_WithMeetingId_ReturnsMeetingInfo() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var meetingInfo = await CompatTeamsInfo.GetMeetingInfoAsync( + turnContext, + _meetingId, + cancellationToken); + + Assert.NotNull(meetingInfo); + Assert.NotNull(meetingInfo.Details); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetMeetingParticipantAsync_WithParticipantId_ReturnsParticipant() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var participant = await CompatTeamsInfo.GetMeetingParticipantAsync( + turnContext, + _meetingId, + _userId, + _tenantId, + cancellationToken); + + Assert.NotNull(participant); + Assert.NotNull(participant.User); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMeetingNotificationAsync_SendsNotification() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + // Create a simple targeted meeting notification + // Note: In real scenarios, you would construct the proper notification object + // with surfaces and content according to the Teams schema + var notification = new TargetedMeetingNotification + { + Value = new TargetedMeetingNotificationValue + { + Recipients = new List { _userId }, + Surfaces = new List + { + new MeetingStageSurface() + { + ContentType = ContentType.Task, + Content = new TaskModuleContinueResponse + { + Value = new TaskModuleTaskInfo + { + Title = "Test Notification", + Url = "https://www.example.com", + Height = 200, + Width = 400 + } + } + } + } + } + }; + + var response = await CompatTeamsInfo.SendMeetingNotificationAsync( + turnContext, + notification, + _meetingId, + cancellationToken); + + Assert.NotNull(response); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetTeamDetailsAsync_WithTeamId_ReturnsTeamDetails() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var teamDetails = await CompatTeamsInfo.GetTeamDetailsAsync( + turnContext, + _teamId, + cancellationToken); + + Assert.NotNull(teamDetails); + Assert.NotNull(teamDetails.Id); + Assert.NotNull(teamDetails.Name); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetTeamChannelsAsync_WithTeamId_ReturnsChannels() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var channels = await CompatTeamsInfo.GetTeamChannelsAsync( + turnContext, + _teamId, + cancellationToken); + + Assert.NotNull(channels); + Assert.NotEmpty(channels); + + var firstChannel = channels[0]; + Assert.NotNull(firstChannel.Id); + Assert.NotNull(firstChannel.Name); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMessageToListOfUsersAsync_ReturnsOperationId() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "Test message" + }; + var members = new List + { + new TeamMember(_userId), + new TeamMember(_userId), + new TeamMember(_userId), + new TeamMember(_userId), + new TeamMember(_userId) + + }; + + var operationId = await CompatTeamsInfo.SendMessageToListOfUsersAsync( + turnContext, + activity, + members, + _tenantId, + cancellationToken); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMessageToListOfChannelsAsync_ReturnsOperationId() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "Test message" + }; + var channels = new List + { + new TeamMember(_channelId) + }; + + var operationId = await CompatTeamsInfo.SendMessageToListOfChannelsAsync( + turnContext, + activity, + channels, + _tenantId, + cancellationToken); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMessageToAllUsersInTeamAsync_ReturnsOperationId() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "Test message to team" + }; + + var operationId = await CompatTeamsInfo.SendMessageToAllUsersInTeamAsync( + turnContext, + activity, + _teamId, + _tenantId, + cancellationToken); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMessageToAllUsersInTenantAsync_ReturnsOperationId() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "Test message to tenant" + }; + + var operationId = await CompatTeamsInfo.SendMessageToAllUsersInTenantAsync( + turnContext, + activity, + _tenantId, + cancellationToken); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + }, + CancellationToken.None); + } + + [Fact] + public async Task SendMessageToTeamsChannelAsync_CreatesConversationAndSendsMessage() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "Test message to channel" + }; + var botAppId = Environment.GetEnvironmentVariable("MicrosoftAppId") ?? string.Empty; + + var result = await CompatTeamsInfo.SendMessageToTeamsChannelAsync( + turnContext, + activity, + _channelId, + botAppId, + cancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Item1); // ConversationReference + Assert.NotNull(result.Item2); // ActivityId + }, + CancellationToken.None); + } + + [Fact] + public async Task GetOperationStateAsync_WithOperationId_ReturnsState() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var state = await CompatTeamsInfo.GetOperationStateAsync( + turnContext, + operationId, + cancellationToken); + + Assert.NotNull(state); + Assert.NotNull(state.State); + }, + CancellationToken.None); + } + + [Fact] + public async Task GetPagedFailedEntriesAsync_WithOperationId_ReturnsFailedEntries() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + var response = await CompatTeamsInfo.GetPagedFailedEntriesAsync( + turnContext, + operationId, + cancellationToken: cancellationToken); + + Assert.NotNull(response); + }, + CancellationToken.None); + } + + [Fact] + public async Task CancelOperationAsync_WithOperationId_CancelsOperation() + { + var adapter = InitializeCompatAdapter(); + var conversationReference = CreateConversationReference(_conversationId); + var operationId = "amer_9e0e3ba8-c562-440f-ba9d-10603ee31837"; + + await adapter.ContinueConversationAsync( + string.Empty, + conversationReference, + async (turnContext, cancellationToken) => + { + await CompatTeamsInfo.CancelOperationAsync( + turnContext, + operationId, + cancellationToken); + + // If no exception is thrown, the operation succeeded + Assert.True(true); + }, + CancellationToken.None); + } + + private CompatAdapter InitializeCompatAdapter() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(configuration); + services.AddCompatAdapter(); + services.AddLogging(configure => configure.AddConsole()); + + var serviceProvider = services.BuildServiceProvider(); + CompatAdapter compatAdapter = (CompatAdapter)serviceProvider.GetRequiredService(); + return compatAdapter; + } + + private ConversationReference CreateConversationReference(string conversationId) + { + return new ConversationReference + { + ChannelId = "msteams", + ServiceUrl = _serviceUrl, + Conversation = new ConversationAccount + { + Id = conversationId + } + }; + } + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs new file mode 100644 index 00000000..85a583a6 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/ConversationClientTest.cs @@ -0,0 +1,717 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Bot.Core.Tests; + +public class ConversationClientTest +{ + private readonly ServiceProvider _serviceProvider; + private readonly ConversationClient _conversationClient; + private readonly Uri _serviceUrl; + + public ConversationClientTest() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddBotApplication(); + _serviceProvider = services.BuildServiceProvider(); + _conversationClient = _serviceProvider.GetRequiredService(); + _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); + } + + [Fact] + public async Task SendActivityDefault() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + SendActivityResponse res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(res); + Assert.NotNull(res.Id); + } + + + [Fact] + public async Task SendActivityToChannel() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set") + } + }; + SendActivityResponse res = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(res); + Assert.NotNull(res.Id); + } + + [Fact] + public async Task SendActivityToPersonalChat_FailsWithBad_ConversationId() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message from Automated tests, running in SDK `{BotApplication.Version}` at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = "a:1" + } + }; + + await Assert.ThrowsAsync(() + => _conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task UpdateActivity() + { + // First send an activity to get an ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Original message from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Now update the activity + CoreActivity updatedActivity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Updated message from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + }; + + UpdateActivityResponse updateResponse = await _conversationClient.UpdateActivityAsync( + activity.Conversation.Id, + sendResponse.Id, + updatedActivity, + cancellationToken: CancellationToken.None); + + Assert.NotNull(updateResponse); + Assert.NotNull(updateResponse.Id); + } + + [Fact] + public async Task DeleteActivity() + { + // First send an activity to get an ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message to delete from Automated tests at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Add a delay for 5 seconds + await Task.Delay(TimeSpan.FromSeconds(5)); + + // Now delete the activity + await _conversationClient.DeleteActivityAsync( + activity.Conversation.Id, + sendResponse.Id, + _serviceUrl, + cancellationToken: CancellationToken.None); + + // If no exception was thrown, the delete was successful + } + + [Fact] + public async Task GetConversationMembers() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + IList members = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log members + Console.WriteLine($"Found {members.Count} members in conversation {conversationId}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + [Fact] + public async Task GetConversationMember() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + ConversationAccount member = await _conversationClient.GetConversationMemberAsync( + conversationId, + userId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(member); + + // Log member + Console.WriteLine($"Found member in conversation {conversationId}:"); + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + + + [Fact] + public async Task GetConversationMembersInChannel() + { + string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); + + IList members = await _conversationClient.GetConversationMembersAsync( + channelId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log members + Console.WriteLine($"Found {members.Count} members in channel {channelId}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + [Fact] + public async Task GetActivityMembers() + { + // First send an activity to get an activity ID + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Message for GetActivityMembers test at `{DateTime.UtcNow:s}`" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set") + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + // Now get the members of this activity + IList members = await _conversationClient.GetActivityMembersAsync( + activity.Conversation.Id, + sendResponse.Id, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + // Log activity members + Console.WriteLine($"Found {members.Count} members for activity {sendResponse.Id}:"); + foreach (ConversationAccount member in members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + } + + // TODO: This doesn't work + [Fact(Skip = "Method not allowed by API")] + public async Task GetConversations() + { + GetConversationsResponse response = await _conversationClient.GetConversationsAsync( + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Conversations); + Assert.NotEmpty(response.Conversations); + + // Log conversations + Console.WriteLine($"Found {response.Conversations.Count} conversations:"); + foreach (ConversationMembers conversation in response.Conversations) + { + Console.WriteLine($" - Conversation Id: {conversation.Id}"); + Assert.NotNull(conversation); + Assert.NotNull(conversation.Id); + + if (conversation.Members != null && conversation.Members.Any()) + { + Console.WriteLine($" Members ({conversation.Members.Count}):"); + foreach (ConversationAccount member in conversation.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + } + } + } + + [Fact] + public async Task CreateConversation_WithMembers() + { + // Create a 1-on-1 conversation with a member + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + // TODO: This is required for some reason. Should it be required in the api? + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation: {response.Id}"); + Console.WriteLine($" ActivityId: {response.ActivityId}"); + Console.WriteLine($" ServiceUrl: {response.ServiceUrl}"); + + // Send a message to the newly created conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to new conversation at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't work + [Fact(Skip = "Incorrect conversation creation parameters")] + public async Task CreateConversation_WithGroup() + { + // Create a group conversation + ConversationParameters parameters = new() + { + IsGroup = true, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + }, + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID_2") ?? throw new InvalidOperationException("TEST_USER_ID_2 environment variable not set"), + } + ], + TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created group conversation: {response.Id}"); + + // Send a message to the newly created group conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to new group conversation at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't work + [Fact(Skip = "Incorrect conversation creation parameters")] + public async Task CreateConversation_WithTopicName() + { + // Create a conversation with a topic name + ConversationParameters parameters = new() + { + IsGroup = true, + TopicName = $"Test Conversation - {DateTime.UtcNow:s}", + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + TenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation with topic '{parameters.TopicName}': {response.Id}"); + + // Send a message to the newly created conversation + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Test message to conversation with topic name at {DateTime.UtcNow:s}" } }, + ServiceUrl = _serviceUrl, + Conversation = new() + { + Id = response.Id + } + }; + + SendActivityResponse sendResponse = await _conversationClient.SendActivityAsync(activity, cancellationToken: CancellationToken.None); + Assert.NotNull(sendResponse); + Assert.NotNull(sendResponse.Id); + + Console.WriteLine($" Sent message with activity ID: {sendResponse.Id}"); + } + + // TODO: This doesn't fail, but doesn't actually create the initial activity + [Fact] + public async Task CreateConversation_WithInitialActivity() + { + // Create a conversation with an initial message + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + Activity = new CoreActivity + { + Type = ActivityType.Message, + Properties = { { "text", $"Initial message sent at {DateTime.UtcNow:s}" } }, + }, + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + // Assert.NotNull(response.ActivityId); // Should have an activity ID since we sent an initial message + + Console.WriteLine($"Created conversation with initial activity: {response.Id}"); + Console.WriteLine($" Initial activity ID: {response.ActivityId}"); + } + + [Fact] + public async Task CreateConversation_WithChannelData() + { + // Create a conversation with channel-specific data + ConversationParameters parameters = new() + { + IsGroup = false, + Members = + [ + new() + { + Id = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"), + } + ], + ChannelData = new + { + teamsChannelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") + }, + TenantId = Environment.GetEnvironmentVariable("AzureAd__TenantId") ?? throw new InvalidOperationException("AzureAd__TenantId environment variable not set") + }; + + CreateConversationResponse response = await _conversationClient.CreateConversationAsync( + parameters, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Created conversation with channel data: {response.Id}"); + } + + [Fact] + public async Task GetConversationPagedMembers() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + Console.WriteLine($"Found {result.Members.Count} members in page:"); + foreach (ConversationAccount member in result.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + Assert.NotNull(member); + Assert.NotNull(member.Id); + } + + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Continuation token: {result.ContinuationToken}"); + } + } + + [Fact(Skip = "PageSize parameter not respected by API")] + public async Task GetConversationPagedMembers_WithPageSize() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + PagedMembersResult result = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + pageSize: 1, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + Assert.Single(result.Members); + + Console.WriteLine($"Found {result.Members.Count} members with pageSize=1:"); + foreach (ConversationAccount member in result.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // If there's a continuation token, get the next page + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Getting next page with continuation token..."); + + PagedMembersResult nextPage = await _conversationClient.GetConversationPagedMembersAsync( + conversationId, + _serviceUrl, + pageSize: 1, + continuationToken: result.ContinuationToken, + cancellationToken: CancellationToken.None); + + Assert.NotNull(nextPage); + Assert.NotNull(nextPage.Members); + + Console.WriteLine($"Found {nextPage.Members.Count} members in next page:"); + foreach (ConversationAccount member in nextPage.Members) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + } + } + + [Fact(Skip = "Method not allowed by API")] + public async Task DeleteConversationMember() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Get members before deletion + IList membersBefore = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(membersBefore); + Assert.NotEmpty(membersBefore); + + Console.WriteLine($"Members before deletion: {membersBefore.Count}"); + foreach (ConversationAccount member in membersBefore) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // Delete the test user + string memberToDelete = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + // Verify the member is in the conversation before attempting to delete + Assert.Contains(membersBefore, m => m.Id == memberToDelete); + + await _conversationClient.DeleteConversationMemberAsync( + conversationId, + memberToDelete, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Console.WriteLine($"Deleted member: {memberToDelete}"); + + // Get members after deletion + IList membersAfter = await _conversationClient.GetConversationMembersAsync( + conversationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(membersAfter); + + Console.WriteLine($"Members after deletion: {membersAfter.Count}"); + foreach (ConversationAccount member in membersAfter) + { + Console.WriteLine($" - Id: {member.Id}, Name: {member.Name}"); + } + + // Verify the member was deleted + Assert.DoesNotContain(membersAfter, m => m.Id == memberToDelete); + } + + [Fact(Skip = "Unknown activity type error")] + public async Task SendConversationHistory() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Create a transcript with historic activities + Transcript transcript = new() + { + Activities = + [ + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 1" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }, + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 2" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + }, + new() + { + Type = ActivityType.Message, + Id = Guid.NewGuid().ToString(), + Properties = { { "text", "Historic message 3" } }, + ServiceUrl = _serviceUrl, + Conversation = new() { Id = conversationId } + } + ] + }; + + SendConversationHistoryResponse response = await _conversationClient.SendConversationHistoryAsync( + conversationId, + transcript, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + + Console.WriteLine($"Sent conversation history with {transcript.Activities?.Count} activities"); + Console.WriteLine($"Response ID: {response.Id}"); + } + + [Fact(Skip = "Attachment upload endpoint not found")] + public async Task UploadAttachment() + { + string conversationId = Environment.GetEnvironmentVariable("TEST_CONVERSATIONID") ?? throw new InvalidOperationException("TEST_ConversationId environment variable not set"); + + // Create a simple text file as an attachment + string fileContent = "This is a test attachment file created at " + DateTime.UtcNow.ToString("s"); + byte[] fileBytes = System.Text.Encoding.UTF8.GetBytes(fileContent); + + AttachmentData attachmentData = new() + { + Type = "text/plain", + Name = "test-attachment.txt", + OriginalBase64 = fileBytes + }; + + UploadAttachmentResponse response = await _conversationClient.UploadAttachmentAsync( + conversationId, + attachmentData, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + + Console.WriteLine($"Uploaded attachment: {attachmentData.Name}"); + Console.WriteLine($" Attachment ID: {response.Id}"); + Console.WriteLine($" Content-Type: {attachmentData.Type}"); + Console.WriteLine($" Size: {fileBytes.Length} bytes"); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj new file mode 100644 index 00000000..b7aad5b2 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/Microsoft.Teams.Bot.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + ../.runsettings + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs new file mode 100644 index 00000000..861a4ad2 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/TeamsApiClientTests.cs @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Bot.Apps; + +namespace Microsoft.Bot.Core.Tests; + +public class TeamsApiClientTests +{ + private readonly ServiceProvider _serviceProvider; + private readonly TeamsApiClient _teamsClient; + private readonly Uri _serviceUrl; + + public TeamsApiClientTests() + { + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddTeamsBotApplication(); + _serviceProvider = services.BuildServiceProvider(); + _teamsClient = _serviceProvider.GetRequiredService(); + _serviceUrl = new Uri(Environment.GetEnvironmentVariable("TEST_SERVICEURL") ?? "https://smba.trafficmanager.net/teams/"); + } + + #region Team Operations Tests + + [Fact] + public async Task FetchChannelList() + { + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + ChannelList result = await _teamsClient.FetchChannelListAsync( + teamId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Channels); + Assert.NotEmpty(result.Channels); + + Console.WriteLine($"Found {result.Channels.Count} channels in team {teamId}:"); + foreach (var channel in result.Channels) + { + Console.WriteLine($" - Id: {channel.Id}, Name: {channel.Name}"); + Assert.NotNull(channel); + Assert.NotNull(channel.Id); + } + } + + [Fact] + public async Task FetchChannelList_FailsWithInvalidTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("invalid-team-id", _serviceUrl)); + } + + [Fact] + public async Task FetchTeamDetails() + { + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + TeamDetails result = await _teamsClient.FetchTeamDetailsAsync( + teamId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + + Console.WriteLine($"Team details for {teamId}:"); + Console.WriteLine($" - Id: {result.Id}"); + Console.WriteLine($" - Name: {result.Name}"); + Console.WriteLine($" - AAD Group Id: {result.AadGroupId}"); + Console.WriteLine($" - Channel Count: {result.ChannelCount}"); + Console.WriteLine($" - Member Count: {result.MemberCount}"); + Console.WriteLine($" - Type: {result.Type}"); + } + + [Fact] + public async Task FetchTeamDetails_FailsWithInvalidTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchTeamDetailsAsync("invalid-team-id", _serviceUrl)); + } + + #endregion + + #region Meeting Operations Tests + + [Fact] + public async Task FetchMeetingInfo() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + + MeetingInfo result = await _teamsClient.FetchMeetingInfoAsync( + meetingId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + //Assert.NotNull(result.Id); + + Console.WriteLine($"Meeting info for {meetingId}:"); + + if (result.Details != null) + { + Console.WriteLine($" - Title: {result.Details.Title}"); + Console.WriteLine($" - Type: {result.Details.Type}"); + Console.WriteLine($" - Join URL: {result.Details.JoinUrl}"); + Console.WriteLine($" - Scheduled Start: {result.Details.ScheduledStartTime}"); + Console.WriteLine($" - Scheduled End: {result.Details.ScheduledEndTime}"); + } + if (result.Organizer != null) + { + Console.WriteLine($" - Organizer: {result.Organizer.Name} ({result.Organizer.Id})"); + } + } + + [Fact] + public async Task FetchMeetingInfo_FailsWithInvalidMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchMeetingInfoAsync("invalid-meeting-id", _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + string tenantId = Environment.GetEnvironmentVariable("TEST_TENANTID") ?? throw new InvalidOperationException("TEST_TENANTID environment variable not set"); + + MeetingParticipant result = await _teamsClient.FetchParticipantAsync( + meetingId, + participantId, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Participant info for {participantId} in meeting {meetingId}:"); + if (result.User != null) + { + Console.WriteLine($" - User Id: {result.User.Id}"); + Console.WriteLine($" - User Name: {result.User.Name}"); + } + if (result.Meeting != null) + { + Console.WriteLine($" - Role: {result.Meeting.Role}"); + Console.WriteLine($" - In Meeting: {result.Meeting.InMeeting}"); + } + } + + [Fact(Skip = "Requires active meeting context")] + public async Task SendMeetingNotification() + { + string meetingId = Environment.GetEnvironmentVariable("TEST_MEETINGID") ?? throw new InvalidOperationException("TEST_MEETINGID environment variable not set"); + string participantId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + var notification = new TargetedMeetingNotification + { + Value = new TargetedMeetingNotificationValue + { + Recipients = [participantId], + Surfaces = + [ + new MeetingNotificationSurface + { + Surface = "meetingStage", + ContentType = "task", + Content = new { title = "Test Notification", url = "https://example.com" } + } + ] + } + }; + + MeetingNotificationResponse result = await _teamsClient.SendMeetingNotificationAsync( + meetingId, + notification, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Meeting notification sent to meeting {meetingId}"); + if (result.RecipientsFailureInfo != null && result.RecipientsFailureInfo.Count > 0) + { + Console.WriteLine($"Failed recipients:"); + foreach (var failure in result.RecipientsFailureInfo) + { + Console.WriteLine($" - {failure.RecipientMri}: {failure.ErrorCode} - {failure.FailureReason}"); + } + } + } + + #endregion + + #region Batch Message Operations Tests + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToListOfUsers() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new InvalidOperationException("TEST_USER_ID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Batch message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + IList members = + [ + new TeamMember(userId) + ]; + + string operationId = await _teamsClient.SendMessageToListOfUsersAsync( + activity, + members, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Batch message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToAllUsersInTenant() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Tenant-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + string operationId = await _teamsClient.SendMessageToAllUsersInTenantAsync( + activity, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Tenant-wide message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToAllUsersInTeam() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string teamId = Environment.GetEnvironmentVariable("TEST_TEAMID") ?? throw new InvalidOperationException("TEST_TEAMID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Team-wide message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + string operationId = await _teamsClient.SendMessageToAllUsersInTeamAsync( + activity, + teamId, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Team-wide message sent. Operation ID: {operationId}"); + } + + [Fact(Skip = "Batch operations require special permissions")] + public async Task SendMessageToListOfChannels() + { + string tenantId = Environment.GetEnvironmentVariable("TENANT_ID") ?? throw new InvalidOperationException("TENANT_ID environment variable not set"); + string channelId = Environment.GetEnvironmentVariable("TEST_CHANNELID") ?? throw new InvalidOperationException("TEST_CHANNELID environment variable not set"); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Properties = { { "text", $"Channel batch message from Automated tests at `{DateTime.UtcNow:s}`" } } + }; + + IList channels = + [ + new TeamMember(channelId) + ]; + + string operationId = await _teamsClient.SendMessageToListOfChannelsAsync( + activity, + channels, + tenantId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(operationId); + Assert.NotEmpty(operationId); + + Console.WriteLine($"Channel batch message sent. Operation ID: {operationId}"); + } + + #endregion + + #region Batch Operation Management Tests + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task GetOperationState() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + BatchOperationState result = await _teamsClient.GetOperationStateAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.State); + + Console.WriteLine($"Operation state for {operationId}:"); + Console.WriteLine($" - State: {result.State}"); + Console.WriteLine($" - Total Entries: {result.TotalEntriesCount}"); + if (result.StatusMap != null) + { + Console.WriteLine($" - Success: {result.StatusMap.Success}"); + Console.WriteLine($" - Failed: {result.StatusMap.Failed}"); + Console.WriteLine($" - Throttled: {result.StatusMap.Throttled}"); + Console.WriteLine($" - Pending: {result.StatusMap.Pending}"); + } + if (result.RetryAfter != null) + { + Console.WriteLine($" - Retry After: {result.RetryAfter}"); + } + } + + [Fact] + public async Task GetOperationState_FailsWithInvalidOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetOperationStateAsync("invalid-operation-id", _serviceUrl)); + } + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task GetPagedFailedEntries() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + BatchFailedEntriesResponse result = await _teamsClient.GetPagedFailedEntriesAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + + Console.WriteLine($"Failed entries for operation {operationId}:"); + if (result.FailedEntries != null && result.FailedEntries.Count > 0) + { + foreach (var entry in result.FailedEntries) + { + Console.WriteLine($" - Id: {entry.Id}, Error: {entry.Error}"); + } + } + else + { + Console.WriteLine(" No failed entries"); + } + + if (!string.IsNullOrWhiteSpace(result.ContinuationToken)) + { + Console.WriteLine($"Continuation token: {result.ContinuationToken}"); + } + } + + [Fact(Skip = "Requires valid operation ID from batch operation")] + public async Task CancelOperation() + { + string operationId = Environment.GetEnvironmentVariable("TEST_OPERATION_ID") ?? throw new InvalidOperationException("TEST_OPERATION_ID environment variable not set"); + + await _teamsClient.CancelOperationAsync( + operationId, + _serviceUrl, + cancellationToken: CancellationToken.None); + + Console.WriteLine($"Operation {operationId} cancelled successfully"); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public async Task FetchChannelList_ThrowsOnNullTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchChannelList_ThrowsOnEmptyTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("", _serviceUrl)); + } + + [Fact] + public async Task FetchChannelList_ThrowsOnNullServiceUrl() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchChannelListAsync("team-id", null!)); + } + + [Fact] + public async Task FetchTeamDetails_ThrowsOnNullTeamId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchTeamDetailsAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchMeetingInfo_ThrowsOnNullMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchMeetingInfoAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullMeetingId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync(null!, "participant", "tenant", _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullParticipantId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync("meeting", null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task FetchParticipant_ThrowsOnNullTenantId() + { + await Assert.ThrowsAsync(() + => _teamsClient.FetchParticipantAsync("meeting", "participant", null!, _serviceUrl)); + } + + [Fact] + public async Task SendMeetingNotification_ThrowsOnNullMeetingId() + { + var notification = new TargetedMeetingNotification(); + await Assert.ThrowsAsync(() + => _teamsClient.SendMeetingNotificationAsync(null!, notification, _serviceUrl)); + } + + [Fact] + public async Task SendMeetingNotification_ThrowsOnNullNotification() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMeetingNotificationAsync("meeting", null!, _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(null!, [new TeamMember("id")], "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnNullMembers() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(activity, null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfUsers_ThrowsOnEmptyMembers() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfUsersAsync(activity, [], "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTenant_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTenantAsync(null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTenant_ThrowsOnNullTenantId() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTenantAsync(activity, null!, _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTeam_ThrowsOnNullActivity() + { + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTeamAsync(null!, "team", "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToAllUsersInTeam_ThrowsOnNullTeamId() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToAllUsersInTeamAsync(activity, null!, "tenant", _serviceUrl)); + } + + [Fact] + public async Task SendMessageToListOfChannels_ThrowsOnEmptyChannels() + { + var activity = new CoreActivity { Type = ActivityType.Message }; + await Assert.ThrowsAsync(() + => _teamsClient.SendMessageToListOfChannelsAsync(activity, [], "tenant", _serviceUrl)); + } + + [Fact] + public async Task GetOperationState_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetOperationStateAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task GetPagedFailedEntries_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.GetPagedFailedEntriesAsync(null!, _serviceUrl)); + } + + [Fact] + public async Task CancelOperation_ThrowsOnNullOperationId() + { + await Assert.ThrowsAsync(() + => _teamsClient.CancelOperationAsync(null!, _serviceUrl)); + } + + #endregion +} diff --git a/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md b/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md new file mode 100644 index 00000000..125a5289 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.Tests/readme.md @@ -0,0 +1,21 @@ +# Microsoft.Bot.Core.Tests + +To run these tests we need to configure the environment variables using a `.runsettings` file, that should be localted in `core/` folder. + + +```xml + + + + + a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT + https://login.microsoftonline.com/ + + + https://api.botframework.com/.default + ClientSecret + + + + +``` \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs new file mode 100644 index 00000000..302327d0 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/BotApplicationTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class BotApplicationTests +{ + [Fact] + public void Constructor_InitializesProperties() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + + BotApplication botApp = new(conversationClient, userTokenClient, logger, CreateOptions("test-app-id")); + Assert.NotNull(botApp); + Assert.NotNull(botApp.ConversationClient); + Assert.NotNull(botApp.UserTokenClient); + Assert.NotNull(botApp.UserTokenClient); + } + + + + [Fact] + public async Task ProcessAsync_WithNullHttpContext_ThrowsArgumentNullException() + { + BotApplication botApp = CreateBotApplication(); + + await Assert.ThrowsAsync(() => + botApp.ProcessAsync(null!)); + } + + [Fact] + public async Task ProcessAsync_WithValidActivity_ProcessesSuccessfully() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Properties["text"] = "Test message"; + + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + bool onActivityCalled = false; + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + Assert.True(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_WithMiddleware_ExecutesMiddleware() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + bool middlewareCalled = false; + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + middlewareCalled = true; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.UseMiddleware(mockMiddleware.Object); + + bool onActivityCalled = false; + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + Assert.True(middlewareCalled); + Assert.True(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_WithException_ThrowsBotHandlerException() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => throw new InvalidOperationException("Test exception"); + + BotHandlerException exception = await Assert.ThrowsAsync(() => + botApp.ProcessAsync(httpContext)); + + Assert.Equal("Error processing activity", exception.Message); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Use_AddsMiddlewareToChain() + { + BotApplication botApp = CreateBotApplication(); + + Mock mockMiddleware = new(); + + ITurnMiddleware result = botApp.UseMiddleware(mockMiddleware.Object); + + Assert.NotNull(result); + } + + [Fact] + public async Task SendActivityAsync_WithValidActivity_SendsSuccessfully() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + SendActivityResponse? result = await botApp.SendActivityAsync(activity); + + Assert.NotNull(result); + Assert.Contains("activity123", result.Id); + } + + [Fact] + public async Task SendActivityAsync_WithNullActivity_ThrowsArgumentNullException() + { + BotApplication botApp = CreateBotApplication(); + + await Assert.ThrowsAsync(() => + botApp.SendActivityAsync(null!)); + } + + private static BotApplicationOptions CreateOptions(string appId) => + new() { AppId = appId }; + + private static BotApplication CreateBotApplication() => + new(CreateMockConversationClient(), CreateMockUserTokenClient(), NullLogger.Instance); + + private static ConversationClient CreateMockConversationClient() + { + Mock mockHttpClient = new(); + return new ConversationClient(mockHttpClient.Object); + } + + private static UserTokenClient CreateMockUserTokenClient() + { + Mock mockHttpClient = new(); + NullLogger logger = NullLogger.Instance; + Mock mockConfiguration = new(); + return new UserTokenClient(mockHttpClient.Object, mockConfiguration.Object, logger); + } + + private static DefaultHttpContext CreateHttpContextWithActivity(CoreActivity activity) + { + DefaultHttpContext httpContext = new(); + string activityJson = activity.ToJson(); + byte[] bodyBytes = Encoding.UTF8.GetBytes(activityJson); + httpContext.Request.Body = new MemoryStream(bodyBytes); + httpContext.Request.ContentType = "application/json"; + return httpContext; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs new file mode 100644 index 00000000..9eb027ca --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/ConversationClientTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class ConversationClientTests +{ + [Fact] + public async Task SendActivityAsync_WithValidActivity_SendsSuccessfully() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + SendActivityResponse result = await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(result); + Assert.Contains("activity123", result.Id); + } + + [Fact] + public async Task SendActivityAsync_WithNullActivity_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(null!)); + } + + [Fact] + public async Task SendActivityAsync_WithNullConversation_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithNullConversationId_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation() { Id = null! }, + ServiceUrl = new Uri("https://test.service.url/") + }; ; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithNullServiceUrl_ThrowsArgumentNullException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" } + }; + + await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + } + + [Fact] + public async Task SendActivityAsync_WithHttpError_ThrowsHttpRequestException() + { + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("Bad request error") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + HttpRequestException exception = await Assert.ThrowsAsync(() => + conversationClient.SendActivityAsync(activity)); + + Assert.Contains("Error sending activity", exception.Message); + Assert.Contains("BadRequest", exception.Message); + } + + [Fact] + public async Task SendActivityAsync_ConstructsCorrectUrl() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, capturedRequest.Method); + } + + [Fact] + public async Task SendActivityAsync_WithReplyToId_AppendsReplyToIdToUrl() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + ReplyToId = "originalActivity456" + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/originalActivity456", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Post, capturedRequest.Method); + } + + [Fact] + public async Task SendActivityAsync_WithEmptyReplyToId_DoesNotAppendReplyToIdToUrl() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Conversation = new Conversation { Id = "conv123" }, + ServiceUrl = new Uri("https://test.service.url/"), + ReplyToId = "" + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Equal("https://test.service.url/v3/conversations/conv123/activities/", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithAgentsChannel_TruncatesConversationId() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ILogger logger = NullLogger.Instance; + ConversationClient conversationClient = new(httpClient, logger); + + string longConversationId = new('x', 150); + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "agents", + Conversation = new Conversation { Id = longConversationId }, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + string expectedTruncatedId = new('x', 100); + Assert.Equal($"https://test.service.url/v3/conversations/{expectedTruncatedId}/activities/", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithAgentsChannelAndReplyToId_TruncatesConversationIdAndAppendsReplyToId() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ILogger logger = NullLogger.Instance; + ConversationClient conversationClient = new(httpClient, logger); + + string longConversationId = new('x', 150); + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "agents", + Conversation = new Conversation { Id = longConversationId }, + ServiceUrl = new Uri("https://test.service.url/"), + ReplyToId = "replyActivity789" + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + string expectedTruncatedId = new('x', 100); + Assert.Equal($"https://test.service.url/v3/conversations/{expectedTruncatedId}/activities/replyActivity789", capturedRequest.RequestUri?.ToString()); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs new file mode 100644 index 00000000..39669430 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/CoreActivityBuilderTests.cs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class CoreActivityBuilderTests +{ + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + CoreActivityBuilder builder = new(); + CoreActivity activity = builder.Build(); + + Assert.NotNull(activity); + Assert.Null(activity.From); + Assert.Null(activity.Recipient); + Assert.Null(activity.Conversation); + } + + [Fact] + public void Constructor_WithExistingActivity_UsesProvidedActivity() + { + CoreActivity existingActivity = new() + { + Id = "test-id", + }; + + CoreActivityBuilder builder = new(existingActivity); + CoreActivity activity = builder.Build(); + + Assert.Equal("test-id", activity.Id); + } + + [Fact] + public void Constructor_WithNullActivity_ThrowsArgumentNullException() + { + Assert.Throws(() => new CoreActivityBuilder(null!)); + } + + [Fact] + public void WithId_SetsActivityId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithId("test-activity-id") + .Build(); + + Assert.Equal("test-activity-id", activity.Id); + } + + [Fact] + public void WithServiceUrl_SetsServiceUrl() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/teams/"); + + CoreActivity activity = new CoreActivityBuilder() + .WithServiceUrl(serviceUrl) + .Build(); + + Assert.Equal(serviceUrl, activity.ServiceUrl); + } + + [Fact] + public void WithChannelId_SetsChannelId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelId("msteams") + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + } + + [Fact] + public void WithType_SetsActivityType() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + } + + [Fact] + public void WithText_SetsTextContent_As_Property() + { + CoreActivity activity = new CoreActivityBuilder() + .WithProperty("text", "Hello, World!") + .Build(); + + Assert.Equal("Hello, World!", activity.Properties["text"]); + } + + [Fact] + public void WithFrom_SetsSenderAccount() + { + ConversationAccount fromAccount = new() + { + Id = "sender-id", + Name = "Sender Name" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithFrom(fromAccount) + .Build(); + + Assert.Equal("sender-id", activity.From?.Id); + Assert.Equal("Sender Name", activity.From?.Name); + } + + [Fact] + public void WithRecipient_SetsRecipientAccount() + { + ConversationAccount recipientAccount = new() + { + Id = "recipient-id", + Name = "Recipient Name" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithRecipient(recipientAccount) + .Build(); + + Assert.Equal("recipient-id", activity.Recipient?.Id); + Assert.Equal("Recipient Name", activity.Recipient?.Name); + } + + [Fact] + public void WithConversation_SetsConversationInfo() + { + Conversation conversation = new() + { + Id = "conversation-id" + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithConversation(conversation) + .Build(); + + Assert.Equal("conversation-id", activity.Conversation?.Id); + } + + [Fact] + public void WithChannelData_SetsChannelData() + { + ChannelData channelData = new(); + + CoreActivity activity = new CoreActivityBuilder() + .WithChannelData(channelData) + .Build(); + + Assert.NotNull(activity.ChannelData); + } + + [Fact] + public void FluentAPI_CompleteActivity_BuildsCorrectly() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithId("activity-123") + .WithChannelId("msteams") + .WithProperty("text", "Test message") + .WithServiceUrl(new Uri("https://smba.trafficmanager.net/teams/")) + .WithFrom(new ConversationAccount + { + Id = "sender-id", + Name = "Sender" + }) + .WithRecipient(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient" + }) + .WithConversation(new Conversation + { + Id = "conv-id" + }) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("activity-123", activity.Id); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("Test message", activity.Properties["text"]?.ToString()); + Assert.Equal("sender-id", activity.From?.Id); + Assert.Equal("recipient-id", activity.Recipient?.Id); + Assert.Equal("conv-id", activity.Conversation?.Id); + } + + [Fact] + public void FluentAPI_MethodChaining_ReturnsBuilderInstance() + { + CoreActivityBuilder builder = new(); + + CoreActivityBuilder result1 = builder.WithId("id"); + CoreActivityBuilder result2 = builder.WithProperty("text", "text"); + CoreActivityBuilder result3 = builder.WithType(ActivityType.Message); + + Assert.Same(builder, result1); + Assert.Same(builder, result2); + Assert.Same(builder, result3); + } + + [Fact] + public void Build_CalledMultipleTimes_ReturnsSameInstance() + { + CoreActivityBuilder builder = new CoreActivityBuilder() + .WithId("test-id"); + + CoreActivity activity1 = builder.Build(); + CoreActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + } + + [Fact] + public void Builder_ModifyingExistingActivity_PreservesOriginalData() + { + CoreActivity original = new() + { + Id = "original-id", + Type = ActivityType.Message + }; + + CoreActivity modified = new CoreActivityBuilder(original) + .WithId("other-id") + .Build(); + + Assert.Equal("other-id", modified.Id); + Assert.Equal(ActivityType.Message, modified.Type); + } + + [Fact] + public void WithConversationReference_WithNullActivity_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + + Assert.Throws(() => builder.WithConversationReference(null!)); + } + + [Fact] + public void WithConversationReference_WithNullChannelId_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = null, + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullServiceUrl_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = null, + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullConversation_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = null!, + From = new ConversationAccount(), + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullFrom_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = null!, + Recipient = new ConversationAccount() + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullRecipient_ThrowsArgumentNullException() + { + CoreActivityBuilder builder = new(); + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation(), + From = new ConversationAccount(), + Recipient = null! + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_AppliesConversationReference() + { + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.trafficmanager.net/teams/"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-1", Name = "User One" }, + Recipient = new ConversationAccount { Id = "bot-1", Name = "Bot" } + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithConversationReference(sourceActivity) + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal(new Uri("https://smba.trafficmanager.net/teams/"), activity.ServiceUrl); + Assert.Equal("conv-123", activity.Conversation?.Id); + Assert.Equal("bot-1", activity.From?.Id); + Assert.Equal("Bot", activity.From?.Name); + Assert.Equal("user-1", activity.Recipient?.Id); + Assert.Equal("User One", activity.Recipient?.Name); + } + + [Fact] + public void WithConversationReference_SwapsFromAndRecipient() + { + CoreActivity incomingActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-id", Name = "User" }, + Recipient = new ConversationAccount { Id = "bot-id", Name = "Bot" } + }; + + CoreActivity replyActivity = new CoreActivityBuilder() + .WithConversationReference(incomingActivity) + .Build(); + + Assert.Equal("bot-id", replyActivity.From?.Id); + Assert.Equal("Bot", replyActivity.From?.Name); + Assert.Equal("user-id", replyActivity.Recipient?.Id); + Assert.Equal("User", replyActivity.Recipient?.Name); + } + + [Fact] + public void WithChannelData_WithNullValue_SetsToNull() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelData(new ChannelData()) + .WithChannelData(null) + .Build(); + + Assert.Null(activity.ChannelData); + } + + [Fact] + public void WithId_WithEmptyString_SetsEmptyId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithId(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.Id); + } + + [Fact] + public void WithChannelId_WithEmptyString_SetsEmptyChannelId() + { + CoreActivity activity = new CoreActivityBuilder() + .WithChannelId(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.ChannelId); + } + + [Fact] + public void WithType_WithEmptyString_SetsEmptyType() + { + CoreActivity activity = new CoreActivityBuilder() + .WithType(string.Empty) + .Build(); + + Assert.Equal(string.Empty, activity.Type); + } + + [Fact] + public void WithConversationReference_ChainedWithOtherMethods_MaintainsFluentInterface() + { + CoreActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = new Uri("https://test.com"), + Conversation = new Conversation { Id = "conv-123" }, + From = new ConversationAccount { Id = "user-1" }, + Recipient = new ConversationAccount { Id = "bot-1" } + }; + + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(sourceActivity) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("bot-1", activity.From?.Id); + Assert.Equal("user-1", activity.Recipient?.Id); + } + + [Fact] + public void Build_AfterModificationThenBuild_ReflectsChanges() + { + CoreActivityBuilder builder = new CoreActivityBuilder() + .WithId("id-1"); + + CoreActivity activity1 = builder.Build(); + Assert.Equal("id-1", activity1.Id); + + builder.WithId("id-2"); + CoreActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + Assert.Equal("id-2", activity2.Id); + } + + [Fact] + public void IntegrationTest_CreateComplexActivity() + { + Uri serviceUrl = new("https://smba.trafficmanager.net/amer/test/"); + ChannelData channelData = new(); + + CoreActivity activity = new CoreActivityBuilder() + .WithType(ActivityType.Message) + .WithId("msg-001") + .WithServiceUrl(serviceUrl) + .WithChannelId("msteams") + .WithFrom(new ConversationAccount + { + Id = "bot-id", + Name = "Bot" + }) + .WithRecipient(new ConversationAccount + { + Id = "user-id", + Name = "User" + }) + .WithConversation(new Conversation + { + Id = "conv-001" + }) + .WithChannelData(channelData) + .Build(); + + Assert.Equal(ActivityType.Message, activity.Type); + Assert.Equal("msg-001", activity.Id); + Assert.Equal(serviceUrl, activity.ServiceUrl); + Assert.Equal("msteams", activity.ChannelId); + Assert.Equal("bot-id", activity.From?.Id); + Assert.Equal("user-id", activity.Recipient?.Id); + Assert.Equal("conv-001", activity.Conversation?.Id); + Assert.NotNull(activity.ChannelData); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs new file mode 100644 index 00000000..ebef6800 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Hosting; + +public class AddBotApplicationExtensionsTests +{ + private static ServiceProvider BuildServiceProvider(Dictionary configData, string? aadConfigSectionName = null) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + + if (aadConfigSectionName is null) + { + services.AddConversationClient(); + } + else + { + services.AddConversationClient(aadConfigSectionName); + } + + return services.BuildServiceProvider(); + } + + private static void AssertMsalOptions(ServiceProvider serviceProvider, string expectedClientId, string expectedTenantId, string expectedInstance = "https://login.microsoftonline.com/") + { + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.Equal(expectedClientId, msalOptions.ClientId); + Assert.Equal(expectedTenantId, msalOptions.TenantId); + Assert.Equal(expectedInstance, msalOptions.Instance); + } + + [Fact] + public void AddConversationClient_WithBotFrameworkConfig_ConfiguresClientSecret() + { + // Arrange + Dictionary configData = new() + { + ["MicrosoftAppId"] = "test-app-id", + ["MicrosoftAppTenantId"] = "test-tenant-id", + ["MicrosoftAppPassword"] = "test-secret" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-app-id", "test-tenant-id"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.ClientSecret, credential.SourceType); + Assert.Equal("test-secret", credential.ClientSecret); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndClientSecret_ConfiguresClientSecret() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["CLIENT_SECRET"] = "test-client-secret" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.ClientSecret, credential.SourceType); + Assert.Equal("test-client-secret", credential.ClientSecret); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndSystemAssignedMI_ConfiguresSystemAssignedFIC() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["MANAGED_IDENTITY_CLIENT_ID"] = "system" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.SignedAssertionFromManagedIdentity, credential.SourceType); + Assert.Null(credential.ManagedIdentityClientId); // System-assigned + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Null(managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndUserAssignedMI_ConfiguresUserAssignedFIC() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id", + ["MANAGED_IDENTITY_CLIENT_ID"] = "umi-client-id" // Different from CLIENT_ID means FIC + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.NotNull(msalOptions.ClientCredentials); + Assert.Single(msalOptions.ClientCredentials); + CredentialDescription credential = msalOptions.ClientCredentials.First(); + Assert.Equal(CredentialSource.SignedAssertionFromManagedIdentity, credential.SourceType); + Assert.Equal("umi-client-id", credential.ManagedIdentityClientId); + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Null(managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithCoreConfigAndNoManagedIdentity_ConfiguresUMIWithClientId() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "test-client-id", + ["TENANT_ID"] = "test-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "test-client-id", "test-tenant-id"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(AddBotApplicationExtensions.MsalConfigKey); + Assert.Null(msalOptions.ClientCredentials); + + ManagedIdentityOptions managedIdentityOptions = serviceProvider.GetRequiredService>().Value; + Assert.Equal("test-client-id", managedIdentityOptions.UserAssignedClientId); + } + + [Fact] + public void AddConversationClient_WithDefaultSection_ConfiguresFromSection() + { + // AzureAd is the default Section Name + // Arrange + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "azuread-client-id", + ["AzureAd:TenantId"] = "azuread-tenant-id", + ["AzureAd:Instance"] = "https://login.microsoftonline.com/" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + // Assert + AssertMsalOptions(serviceProvider, "azuread-client-id", "azuread-tenant-id"); + } + + [Fact] + public void AddConversationClient_WithCustomSectionName_ConfiguresFromCustomSection() + { + // Arrange + Dictionary configData = new() + { + ["CustomAuth:ClientId"] = "custom-client-id", + ["CustomAuth:TenantId"] = "custom-tenant-id", + ["CustomAuth:Instance"] = "https://login.microsoftonline.com/" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProvider(configData, "CustomAuth"); + + // Assert + AssertMsalOptions(serviceProvider, "custom-client-id", "custom-tenant-id"); + } + + // --- BotApplicationOptions (AppId) tests --- + + private static ServiceProvider BuildServiceProviderForBotApp(Dictionary configData, string? sectionName = null) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + + if (sectionName is null) + services.AddBotApplication(); + else + services.AddBotApplication(sectionName); + + return services.BuildServiceProvider(); + } + + private static string GetAppId(ServiceProvider serviceProvider) => + serviceProvider.GetRequiredService().AppId; + + [Fact] + public void AddBotApplication_WithMicrosoftAppId_SetsAppIdFromMicrosoftAppId() + { + // Arrange + Dictionary configData = new() + { + ["MicrosoftAppId"] = "bf-app-id", + ["MicrosoftAppTenantId"] = "bf-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("bf-app-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithClientId_SetsAppIdFromClientId() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "core-client-id", + ["TENANT_ID"] = "core-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("core-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithAzureAdSection_SetsAppIdFromSection() + { + // Arrange + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "azuread-client-id", + ["AzureAd:TenantId"] = "azuread-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("azuread-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithCustomSection_SetsAppIdFromCustomSection() + { + // Arrange + Dictionary configData = new() + { + ["CustomAuth:ClientId"] = "custom-client-id", + ["CustomAuth:TenantId"] = "custom-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData, "CustomAuth"); + + // Assert + Assert.Equal("custom-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_MicrosoftAppIdTakesPrecedenceOverClientId() + { + // Arrange — both keys present; MicrosoftAppId is highest priority + Dictionary configData = new() + { + ["MicrosoftAppId"] = "bf-app-id", + ["MicrosoftAppTenantId"] = "bf-tenant-id", + ["CLIENT_ID"] = "core-client-id", + ["TENANT_ID"] = "core-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("bf-app-id", GetAppId(serviceProvider)); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj new file mode 100644 index 00000000..fbef6c2e --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Microsoft.Teams.Bot.Core.UnitTests.csproj @@ -0,0 +1,25 @@ + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs new file mode 100644 index 00000000..b9bf2d81 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/MiddlewareTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Bot.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class MiddlewareTests +{ + [Fact] + public async Task BotApplication_Use_AddsMiddlewareToChain() + { + BotApplication botApp = CreateBotApplication(); + + Mock mockMiddleware = new(); + + ITurnMiddleware result = botApp.UseMiddleware(mockMiddleware.Object); + + Assert.NotNull(result); + } + + + [Fact] + public async Task Middleware_ExecutesInOrder() + { + BotApplication botApp = CreateBotApplication(); + + List executionOrder = []; + + Mock mockMiddleware1 = new(); + mockMiddleware1 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + executionOrder.Add(1); + await next(ct); + }) + .Returns(Task.CompletedTask); + + Mock mockMiddleware2 = new(); + mockMiddleware2 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + executionOrder.Add(2); + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.UseMiddleware(mockMiddleware1.Object); + botApp.UseMiddleware(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => + { + executionOrder.Add(3); + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + int[] expected = [1, 2, 3]; + Assert.Equal(expected, executionOrder); + } + + [Fact] + public async Task Middleware_CanShortCircuit() + { + BotApplication botApp = CreateBotApplication(); + + bool secondMiddlewareCalled = false; + bool onActivityCalled = false; + + Mock mockMiddleware1 = new(); + mockMiddleware1 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); // Don't call next + + Mock mockMiddleware2 = new(); + mockMiddleware2 + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => secondMiddlewareCalled = true) + .Returns(Task.CompletedTask); + + botApp.UseMiddleware(mockMiddleware1.Object); + botApp.UseMiddleware(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + botApp.OnActivity = (act, ct) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(httpContext); + + Assert.False(secondMiddlewareCalled); + Assert.False(onActivityCalled); + } + + [Fact] + public async Task Middleware_ReceivesCancellationToken() + { + BotApplication botApp = CreateBotApplication(); + + CancellationToken receivedToken = default; + + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + receivedToken = ct; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.UseMiddleware(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + CancellationTokenSource cts = new(); + + await botApp.ProcessAsync(httpContext, cts.Token); + + Assert.Equal(cts.Token, receivedToken); + } + + [Fact] + public async Task Middleware_ReceivesActivity() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity? receivedActivity = null; + + Mock mockMiddleware = new(); + mockMiddleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(async (app, act, next, ct) => + { + receivedActivity = act; + await next(ct); + }) + .Returns(Task.CompletedTask); + + botApp.UseMiddleware(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + if (activity.Recipient is not null) + { + activity.Recipient.Properties["appId"] = "test-app-id"; + } + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + await botApp.ProcessAsync(httpContext); + + Assert.NotNull(receivedActivity); + Assert.Equal(ActivityType.Message, receivedActivity.Type); + } + + private static BotApplication CreateBotApplication() => + new(CreateMockConversationClient(), CreateMockUserTokenClient(), NullLogger.Instance); + + private static ConversationClient CreateMockConversationClient() + { + Mock mockHttpClient = new(); + return new ConversationClient(mockHttpClient.Object); + } + + private static UserTokenClient CreateMockUserTokenClient() + { + Mock mockHttpClient = new(); + Mock mockConfig = new(); + NullLogger logger = NullLogger.Instance; + return new UserTokenClient(mockHttpClient.Object, mockConfig.Object, logger); + } + + private static DefaultHttpContext CreateHttpContextWithActivity(CoreActivity activity) + { + DefaultHttpContext httpContext = new(); + string activityJson = activity.ToJson(); + byte[] bodyBytes = Encoding.UTF8.GetBytes(activityJson); + httpContext.Request.Body = new MemoryStream(bodyBytes); + httpContext.Request.ContentType = "application/json"; + return httpContext; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs new file mode 100644 index 00000000..d5d0f3e2 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/ActivityExtensibilityTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class ActivityExtensibilityTests +{ + [Fact] + public void CustomActivity_ExtendedProperties_SerializedAndDeserialized() + { + MyCustomActivity customActivity = new() + { + CustomField = "CustomValue" + }; + string json = MyCustomActivity.ToJson(customActivity); + MyCustomActivity deserializedActivity = MyCustomActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.Equal("CustomValue", deserializedActivity.CustomField); + } + + [Fact] + public async Task CustomActivity_ExtendedProperties_SerializedAndDeserialized_Async() + { + string json = """ + { + "type": "message", + "customField": "CustomValue" + } + """; + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + MyCustomActivity? deserializedActivity = await CoreActivity.FromJsonStreamAsync(stream); + Assert.NotNull(deserializedActivity); + Assert.Equal("CustomValue", deserializedActivity!.CustomField); + } + + + [Fact] + public void CustomChannelDataActivity_ExtendedProperties_SerializedAndDeserialized() + { + MyCustomChannelDataActivity customChannelDataActivity = new() + { + ChannelData = new MyChannelData + { + CustomField = "customFieldValue", + MyChannelId = "12345" + } + }; + string json = CoreActivity.ToJson(customChannelDataActivity); + MyCustomChannelDataActivity deserializedActivity = MyCustomChannelDataActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal(ActivityType.Message, deserializedActivity.Type); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); + } + + + [Fact] + public void Deserialize_CustomChannelDataActivity() + { + string json = """ + { + "type": "message", + "channelData": { + "customField": "customFieldValue", + "myChannelId": "12345" + } + } + """; + MyCustomChannelDataActivity deserializedActivity = MyCustomChannelDataActivity.FromActivity(CoreActivity.FromJsonString(json)); + Assert.NotNull(deserializedActivity); + Assert.NotNull(deserializedActivity.ChannelData); + Assert.Equal("customFieldValue", deserializedActivity.ChannelData.CustomField); + Assert.Equal("12345", deserializedActivity.ChannelData.MyChannelId); + } +} + +public class MyCustomActivity : CoreActivity +{ + internal static MyCustomActivity FromActivity(CoreActivity activity) + { + return new MyCustomActivity + { + Type = activity.Type, + ChannelId = activity.ChannelId, + Id = activity.Id, + ServiceUrl = activity.ServiceUrl, + ChannelData = activity.ChannelData, + From = activity.From, + Recipient = activity.Recipient, + Conversation = activity.Conversation, + Entities = activity.Entities, + Attachments = activity.Attachments, + Value = activity.Value, + Properties = activity.Properties, + CustomField = activity.Properties.TryGetValue("customField", out object? customFieldObj) + && customFieldObj is JsonElement jeCustomField + && jeCustomField.ValueKind == JsonValueKind.String + ? jeCustomField.GetString() + : null + }; + } + [JsonPropertyName("customField")] + public string? CustomField { get; set; } +} + + +public class MyChannelData : ChannelData +{ + public MyChannelData() + { + } + public MyChannelData(ChannelData cd) + { + if (cd is not null) + { + if (cd.Properties.TryGetValue("customField", out object? channelIdObj) + && channelIdObj is JsonElement jeChannelId + && jeChannelId.ValueKind == JsonValueKind.String) + { + CustomField = jeChannelId.GetString(); + } + + if (cd.Properties.TryGetValue("myChannelId", out object? mychannelIdObj) + && mychannelIdObj is JsonElement jemyChannelId + && jemyChannelId.ValueKind == JsonValueKind.String) + { + MyChannelId = jemyChannelId.GetString(); + } + } + } + + [JsonPropertyName("customField")] + public string? CustomField { get; set; } + + [JsonPropertyName("myChannelId")] + public string? MyChannelId { get; set; } +} + +public class MyCustomChannelDataActivity : CoreActivity +{ + [JsonPropertyName("channelData")] + public new MyChannelData? ChannelData { get; set; } + + internal static MyCustomChannelDataActivity FromActivity(CoreActivity coreActivity) + { + return new MyCustomChannelDataActivity + { + Type = coreActivity.Type, + ChannelId = coreActivity.ChannelId, + Id = coreActivity.Id, + ServiceUrl = coreActivity.ServiceUrl, + ChannelData = new MyChannelData(coreActivity.ChannelData ?? new Core.Schema.ChannelData()), + Recipient = coreActivity.Recipient, + Conversation = coreActivity.Conversation, + Entities = coreActivity.Entities, + Attachments = coreActivity.Attachments, + Value = coreActivity.Value, + Properties = coreActivity.Properties + }; + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs new file mode 100644 index 00000000..d853c41a --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/CoreActivityTests.cs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class CoreCoreActivityTests +{ + [Fact] + public void Ctor_And_Nulls() + { + CoreActivity a1 = new(); + Assert.NotNull(a1); + Assert.Equal(ActivityType.Message, a1.Type); + + CoreActivity a2 = new() + { + Type = "mytype" + }; + Assert.NotNull(a2); + Assert.Equal("mytype", a2.Type); + } + + [Fact] + public void Json_Nulls_Not_Deserialized() + { + string json = """ + { + "type": "message", + "text": null + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + + string json2 = """ + { + "type": "message" + } + """; + CoreActivity act2 = CoreActivity.FromJsonString(json2); + Assert.NotNull(act2); + Assert.Equal("message", act2.Type); + + } + + [Fact] + public void Accept_Unkown_Primitive_Fields() + { + string json = """ + { + "type": "message", + "text": "hello", + "unknownString": "some string", + "unknownInt": 123, + "unknownBool": true, + "unknownNull": null + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.True(act.Properties.ContainsKey("unknownString")); + Assert.True(act.Properties.ContainsKey("unknownInt")); + Assert.True(act.Properties.ContainsKey("unknownBool")); + Assert.True(act.Properties.ContainsKey("unknownNull")); + Assert.Equal("some string", act.Properties["unknownString"]?.ToString()); + Assert.Equal(123, ((JsonElement)act.Properties["unknownInt"]!).GetInt32()); + Assert.True(((JsonElement)act.Properties["unknownBool"]!).GetBoolean()); + Assert.Null(act.Properties["unknownNull"]); + } + + [Fact] + public void Serialize_Unkown_Primitive_Fields() + { + CoreActivity act = new() + { + Type = ActivityType.Message, + }; + act.Properties["unknownString"] = "some string"; + act.Properties["unknownInt"] = 123; + act.Properties["unknownBool"] = true; + act.Properties["unknownNull"] = null; + act.Properties["unknownLong"] = 1L; + act.Properties["unknownDouble"] = 1.0; + + string json = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json); + Assert.Contains("\"unknownString\": \"some string\"", json); + Assert.Contains("\"unknownInt\": 123", json); + Assert.Contains("\"unknownBool\": true", json); + Assert.Contains("\"unknownNull\": null", json); + Assert.Contains("\"unknownLong\": 1", json); + Assert.Contains("\"unknownDouble\": 1", json); + } + + [Fact] + public void Deserialize_Unkown__Fields_In_KnownObjects() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.NotNull(act.From); + Assert.IsType(act.From); + Assert.Equal("1", act.From!.Id); + Assert.Equal("tester", act.From.Name); + Assert.True(act.From.Properties.ContainsKey("aadObjectId")); + Assert.Equal("123", act.From.Properties["aadObjectId"]?.ToString()); + } + + [Fact] + public void Deserialize_Serialize_Unkown__Fields_In_KnownObjects() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + string json2 = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json2); + Assert.Contains("\"text\": \"hello\"", json2); + Assert.Contains("\"from\": {", json2); + Assert.Contains("\"id\": \"1\"", json2); + Assert.Contains("\"name\": \"tester\"", json2); + Assert.Contains("\"aadObjectId\": \"123\"", json2); + } + + [Fact] + public void Deserialize_Serialize_Entities() + { + string json = """ + { + "type": "message", + "text": "hello", + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ] + } + """; + CoreActivity act = CoreActivity.FromJsonString(json); + string json2 = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json2); + Assert.NotNull(act.Entities); + Assert.Equal(2, act.Entities!.Count); + + } + + + [Fact] + public void Handling_Nulls_from_default_serializer() + { + string json = """ + { + "type": "message", + "text": null, + "unknownString": null + } + """; + CoreActivity? act = JsonSerializer.Deserialize(json); //without default options + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.Null(act.Properties["text"]); + Assert.Null(act.Properties["unknownString"]!); + + string json2 = JsonSerializer.Serialize(act); //without default options + Assert.Contains("\"type\":\"message\"", json2); + Assert.Contains("\"text\":null", json2); + Assert.Contains("\"unknownString\":null", json2); + } + + [Fact] + public void Serialize_With_Properties_Initialized() + { + CoreActivity act = new() + { + Type = ActivityType.Message, + Properties = + { + { "customField", "customValue" } + }, + ChannelData = new() + { + Properties = + { + { "channelCustomField", "channelCustomValue" } + } + }, + Conversation = new() + { + Properties = + { + { "conversationCustomField", "conversationCustomValue" } + } + }, + From = new() + { + Id = "user1", + Properties = + { + { "fromCustomField", "fromCustomValue" } + } + }, + Recipient = new() + { + Id = "bot1", + Properties = + { + { "recipientCustomField", "recipientCustomValue" } + } + + } + }; + string json = act.ToJson(); + Assert.Contains("\"type\": \"message\"", json); + Assert.Contains("\"customField\": \"customValue\"", json); + Assert.Contains("\"channelCustomField\": \"channelCustomValue\"", json); + Assert.Contains("\"conversationCustomField\": \"conversationCustomValue\"", json); + Assert.Contains("\"fromCustomField\": \"fromCustomValue\"", json); + Assert.Contains("\"recipientCustomField\": \"recipientCustomValue\"", json); + } + + + [Fact] + public void CreateReply() + { + CoreActivity act = new() + { + Type = "myActivityType", + Id = "CoreActivity1", + ChannelId = "channel1", + ServiceUrl = new Uri("http://service.url"), + From = new ConversationAccount() + { + Id = "user1", + Name = "User One" + }, + Recipient = new ConversationAccount() + { + Id = "bot1", + Name = "Bot One" + }, + Conversation = new Conversation() + { + Id = "conversation1" + } + }; + CoreActivity reply = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversationReference(act) + .WithProperty("text", "reply") + .Build(); + + Assert.NotNull(reply); + Assert.Equal(ActivityType.Message, reply.Type); + Assert.Equal("reply", reply.Properties["text"]); + Assert.Equal("channel1", reply.ChannelId); + Assert.NotNull(reply.ServiceUrl); + Assert.Equal("http://service.url/", reply.ServiceUrl.ToString()); + Assert.Equal("conversation1", reply.Conversation?.Id); + Assert.Equal("bot1", reply.From?.Id); + Assert.Equal("Bot One", reply.From?.Name); + Assert.Equal("user1", reply.Recipient?.Id); + Assert.Equal("User One", reply.Recipient?.Name); + } + + [Fact] + public async Task DeserializeAsync() + { + string json = """ + { + "type": "message", + "text": "hello", + "from": { + "id": "1", + "name": "tester", + "aadObjectId": "123" + } + } + """; + using MemoryStream ms = new(System.Text.Encoding.UTF8.GetBytes(json)); + CoreActivity? act = await CoreActivity.FromJsonStreamAsync(ms); + Assert.NotNull(act); + Assert.Equal("message", act.Type); + Assert.Equal("hello", act.Properties["text"]?.ToString()); + Assert.NotNull(act.From); + Assert.IsType(act.From); + Assert.Equal("1", act.From.Id); + Assert.Equal("tester", act.From.Name); + Assert.True(act.From.Properties.ContainsKey("aadObjectId")); + Assert.Equal("123", act.From.Properties["aadObjectId"]?.ToString()); + } + + + [Fact] + public async Task DeserializeInvokeWithValueAsync() + { + string json = """ + { + "type": "invoke", + "value": { + "key1": "value1", + "key2": 2 + } + } + """; + using MemoryStream ms = new(System.Text.Encoding.UTF8.GetBytes(json)); + CoreActivity? act = await CoreActivity.FromJsonStreamAsync(ms); + Assert.NotNull(act); + Assert.Equal("invoke", act.Type); + Assert.NotNull(act.Value); + Assert.NotNull(act.Value["key1"]); + Assert.Equal("value1", act.Value["key1"]?.GetValue()); + Assert.Equal(2, act.Value["key2"]?.GetValue()); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs new file mode 100644 index 00000000..c9d1bf18 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Schema/EntitiesTest.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Schema; + +public class EntitiesTest +{ + [Fact] + public void Test_Entity_Deserialization() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "mention", + "mentioned": { + "id": "user1", + "name": "User One" + }, + "text": "User One" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.NotNull(activity); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + JsonNode? e1 = activity.Entities[0]; + Assert.NotNull(e1); + Assert.Equal("mention", e1["type"]?.ToString()); + Assert.NotNull(e1["mentioned"]); + Assert.True(e1["mentioned"]?.AsObject().ContainsKey("id")); + Assert.NotNull(e1["mentioned"]?["id"]); + Assert.Equal("user1", e1["mentioned"]?["id"]?.ToString()); + Assert.Equal("User One", e1["mentioned"]?["name"]?.ToString()); + Assert.Equal("User One", e1["text"]?.ToString()); + } + + [Fact] + public void Entitiy_Serialization() + { + JsonNodeOptions nops = new() + { + PropertyNameCaseInsensitive = false + }; + + CoreActivity activity = new(ActivityType.Message); + JsonObject mentionEntity = new() + { + ["type"] = "mention", + ["mentioned"] = new JsonObject + { + ["id"] = "user1", + ["name"] = "UserOne" + }, + ["text"] = "User One" + }; + activity.Entities = new JsonArray(nops, mentionEntity); + string json = activity.ToJson(); + Assert.NotNull(json); + Assert.Contains("\"type\": \"mention\"", json); + Assert.Contains("\"id\": \"user1\"", json); + Assert.Contains("\"name\": \"UserOne\"", json); + Assert.Contains("\"text\": \"\\u003Cat\\u003EUser One\\u003C/at\\u003E\"", json); + } + + [Fact] + public void Entity_RoundTrip() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "mention", + "mentioned": { + "id": "user1", + "name": "User One" + }, + "text": "User One" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + string serialized = activity.ToJson(); + Assert.NotNull(serialized); + Assert.Contains("\"type\": \"mention\"", serialized); + Assert.Contains("\"id\": \"user1\"", serialized); + Assert.Contains("\"name\": \"User One\"", serialized); + Assert.Contains("\"text\": \"\\u003Cat\\u003EUser One\\u003C/at\\u003E\"", serialized); + } + + [Fact] + public void Test_Unknown_Entity() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "unknownEntityType", + "someProperty": "someValue" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.NotNull(activity); + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + JsonNode? e1 = activity.Entities[0]; + Assert.NotNull(e1); + Assert.Equal("unknownEntityType", e1["type"]?.ToString()); + Assert.Equal("someValue", e1["someProperty"]?.ToString()); + } +} diff --git a/core/test/README.md b/core/test/README.md new file mode 100644 index 00000000..6149a020 --- /dev/null +++ b/core/test/README.md @@ -0,0 +1,30 @@ +# Tests + +.vscode/settings.json + +```json +{ + "dotnet.unitTests.runSettingsPath": "./.runsettings" +} +``` + + +.runsettings +```xml + + + + + test_value + 19:9f2af1bee7cc4a71af25ac72478fd5c6@thread.tacv2 + https://login.microsoftonline.com/ + + + ClientSecret + + Warning + Information + + + +``` \ No newline at end of file diff --git a/core/test/aot-checks/Program.cs b/core/test/aot-checks/Program.cs new file mode 100644 index 00000000..345f33d9 --- /dev/null +++ b/core/test/aot-checks/Program.cs @@ -0,0 +1,5 @@ +using Microsoft.Teams.Bot.Core.Schema; + +CoreActivity coreActivity = CoreActivity.FromJsonString(SampleActivities.TeamsMessage); + +System.Console.WriteLine(coreActivity.ToJson()); \ No newline at end of file diff --git a/core/test/aot-checks/SampleActivities.cs b/core/test/aot-checks/SampleActivities.cs new file mode 100644 index 00000000..757e29e2 --- /dev/null +++ b/core/test/aot-checks/SampleActivities.cs @@ -0,0 +1,68 @@ +internal static class SampleActivities +{ + public const string TeamsMessage = """ + { + "type": "message", + "channelId": "msteams", + "text": "\u003Cat\u003Eridotest\u003C/at\u003E reply to thread", + "id": "1759944781430", + "serviceUrl": "https://smba.trafficmanager.net/amer/50612dbb-0237-4969-b378-8d42590f9c00/", + "channelData": { + "teamsChannelId": "19:6848757105754c8981c67612732d9aa7@thread.tacv2", + "teamsTeamId": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2", + "channel": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2" + }, + "team": { + "id": "19:66P469zibfbsGI-_a0aN_toLTZpyzS6u7CT3TsXdgPw1@thread.tacv2" + }, + "tenant": { + "id": "50612dbb-0237-4969-b378-8d42590f9c00" + } + }, + "from": { + "id": "29:17bUvCasIPKfQIXHvNzcPjD86fwm6GkWc1PvCGP2-NSkNb7AyGYpjQ7Xw-XgTwaHW5JxZ4KMNDxn1kcL8fwX1Nw", + "name": "rido", + "aadObjectId": "b15a9416-0ad3-4172-9210-7beb711d3f70" + }, + "recipient": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "conversation": { + "id": "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", + "isGroup": true, + "conversationType": "channel", + "tenantId": "50612dbb-0237-4969-b378-8d42590f9c00" + }, + "entities": [ + { + "mentioned": { + "id": "28:0b6fe6d1-fece-44f7-9a48-56465e2d5ab8", + "name": "ridotest" + }, + "text": "\u003Cat\u003Eridotest\u003C/at\u003E", + "type": "mention" + }, + { + "locale": "en-US", + "country": "US", + "platform": "Web", + "timezone": "America/Los_Angeles", + "type": "clientInfo" + } + ], + "textFormat": "plain", + "attachments": [ + { + "contentType": "text/html", + "content": "\u003Cp\u003E\u003Cspan itemtype=\u0022http://schema.skype.com/Mention\u0022 itemscope=\u0022\u0022 itemid=\u00220\u0022\u003Eridotest\u003C/span\u003E\u0026nbsp;reply to thread\u003C/p\u003E" + } + ], + "timestamp": "2025-10-08T17:33:01.4953744Z", + "localTimestamp": "2025-10-08T10:33:01.4953744-07:00", + "locale": "en-US", + "localTimezone": "America/Los_Angeles" + } + """; +} \ No newline at end of file diff --git a/core/test/aot-checks/aot-checks.csproj b/core/test/aot-checks/aot-checks.csproj new file mode 100644 index 00000000..b69654e0 --- /dev/null +++ b/core/test/aot-checks/aot-checks.csproj @@ -0,0 +1,17 @@ + + + + + + + + Exe + net10.0 + aot_checks + enable + enable + true + true + + + diff --git a/core/test/msal-config-api/Program.cs b/core/test/msal-config-api/Program.cs new file mode 100644 index 00000000..4844d4aa --- /dev/null +++ b/core/test/msal-config-api/Program.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; + + +string ConversationId = "a:17vxw6pGQOb3Zfh8acXT8m_PqHycYpaFgzu2mFMUfkT-h0UskMctq5ZPPc7FIQxn2bx7rBSm5yE_HeUXsCcKZBrv77RgorB3_1_pAdvMhi39ClxQgawzyQ9GBFkdiwOxT"; +string FromId = "28:56653e9d-2158-46ee-90d7-675c39642038"; +string ServiceUrl = "https://smba.trafficmanager.net/teams/"; + +ConversationClient conversationClient = CreateConversationClient(); +await conversationClient.SendActivityAsync(new CoreActivity +{ + Conversation = new() { Id = ConversationId }, + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId }, + Properties = { { "text", "Test Message" } } + + +}, cancellationToken: default); + +await conversationClient.SendActivityAsync(new CoreActivity +{ + //Text = "Hello from MSAL Config API test!", + Conversation = new() { Id = "bad conversation" }, + ServiceUrl = new Uri(ServiceUrl), + From = new() { Id = FromId } + +}, cancellationToken: default); + + + +static ConversationClient CreateConversationClient() +{ + ServiceCollection services = InitializeDIContainer(); + services.AddConversationClient(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ConversationClient conversationClient = serviceProvider.GetRequiredService(); + return conversationClient; +} + +static ServiceCollection InitializeDIContainer() +{ + IConfigurationBuilder builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables(); + + IConfiguration configuration = builder.Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(configure => configure.AddConsole()); + return services; +} diff --git a/core/test/msal-config-api/msal-config-api.csproj b/core/test/msal-config-api/msal-config-api.csproj new file mode 100644 index 00000000..9907a3f8 --- /dev/null +++ b/core/test/msal-config-api/msal-config-api.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + msal_config_api + enable + enable + false + + + + + + + diff --git a/core/version.json b/core/version.json new file mode 100644 index 00000000..ce1d64b1 --- /dev/null +++ b/core/version.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.0.1-alpha.{height}", + "pathFilters": ["."], + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/next/core$", + "^refs/heads/v\\d+(?:\\.\\d+)?$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + } +} \ No newline at end of file diff --git a/version.json b/version.json index 2d95ee2d..a998c5cf 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "pathFilters": ["./Libraries"], "version": "2.0.6-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$",