diff --git a/.github/workflows/build-complete-samples.yml b/.github/workflows/build-complete-samples.yml index 9e357bec34..11d0be82cb 100644 --- a/.github/workflows/build-complete-samples.yml +++ b/.github/workflows/build-complete-samples.yml @@ -512,6 +512,10 @@ jobs: name: 'graph-chat-migration' version: '8.0.x' + - project_path: 'samples/bot-targeted-messages/csharp/TargetedMessage/TargetedMessage.csproj' + name: 'bot-targeted-messages' + version: '10.0.x' + fail-fast: false name: Build All "${{ matrix.name }}" csharp steps: diff --git a/README.md b/README.md index 4ca704e91c..c6a5a465f6 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ The [Teams Toolkit](https://marketplace.visualstudio.com/items?itemName=TeamsDev | 32 | Auth0 Bot | This sample demonstrates how to authenticate users in a Microsoft Teams bot using Auth0 login and retrieve their profile details. After authentication, the bot displays the user's name, email, and profile picture in an Adaptive Card. | Intermediate | [View][bot-auth0-adaptivecard#cs] ![toolkit-icon](assets/toolkit-icon.png) | [View][bot-auth0-adaptivecard#js] ![toolkit-icon](assets/toolkit-icon.png) | [View][bot-auth0-adaptivecard#python] ![toolkit-icon](assets/toolkit-icon.png) | | [View](/samples/bot-auth0-adaptivecard/csharp/demo-manifest/bot-auth0-adaptivecard.zip) | | 33 | Agent Knowledge Hub | Contoso Knowledge Hub is an intelligent guidance agent built on the Teams SDK, designed to empower students in their academic and career journeys. It offers personalized course recommendations, career-aligned planning, institutional insights, and expert-endorsed AI course recommendations. | Intermediate | [View][agent-knowledge-hub#cs] ![toolkit-icon](assets/toolkit-icon.png) | [View][agent-knowledge-hub#js] ![toolkit-icon](assets/toolkit-icon.png) | [View][agent-knowledge-hub#python] ![toolkit-icon](assets/toolkit-icon.png) | | | | 34 | Bot Shared Channel Events | Microsoft Teams bot can receive transitive member add and remove events in shared channels.| Intermediate | [View][bot-shared-channel-events#cs] ![toolkit-icon](assets/toolkit-icon.png) | | | | | +| 34 | Bot Targeted Messaged | Microsoft Teams bot showcasing targeted messages fearure| Intermediate | [View][bot-targeted-messages#cs] ![toolkit-icon](assets/toolkit-icon.png) | | | | | #### Additional samples | No. | Sample Name | Description | Level | .NET | JavaScript | @@ -580,6 +581,7 @@ The [Teams Toolkit](https://marketplace.visualstudio.com/items?itemName=TeamsDev [msgext-search-auth-config#cs]:samples/msgext-search-auth-config/csharp [msgext-search#cs]:samples/msgext-search/csharp [msgext-action#cs]:samples/msgext-action/csharp +[bot-targeted-messages#cs]:samples/bot-targeted-messages diff --git a/samples/bot-targeted-messages/Images/1.help.png b/samples/bot-targeted-messages/Images/1.help.png new file mode 100644 index 0000000000..6bc3cda156 Binary files /dev/null and b/samples/bot-targeted-messages/Images/1.help.png differ diff --git a/samples/bot-targeted-messages/Images/2.set_reminder.png b/samples/bot-targeted-messages/Images/2.set_reminder.png new file mode 100644 index 0000000000..458d865cb7 Binary files /dev/null and b/samples/bot-targeted-messages/Images/2.set_reminder.png differ diff --git a/samples/bot-targeted-messages/Images/3.reminder_received.png b/samples/bot-targeted-messages/Images/3.reminder_received.png new file mode 100644 index 0000000000..24a03c478a Binary files /dev/null and b/samples/bot-targeted-messages/Images/3.reminder_received.png differ diff --git a/samples/bot-targeted-messages/Images/4.check_active_reminders.png b/samples/bot-targeted-messages/Images/4.check_active_reminders.png new file mode 100644 index 0000000000..f7d690ea4e Binary files /dev/null and b/samples/bot-targeted-messages/Images/4.check_active_reminders.png differ diff --git a/samples/bot-targeted-messages/Images/5.cancel_reminder.png b/samples/bot-targeted-messages/Images/5.cancel_reminder.png new file mode 100644 index 0000000000..70061004e1 Binary files /dev/null and b/samples/bot-targeted-messages/Images/5.cancel_reminder.png differ diff --git a/samples/bot-targeted-messages/Images/targeted_message.gif b/samples/bot-targeted-messages/Images/targeted_message.gif new file mode 100644 index 0000000000..13829351db Binary files /dev/null and b/samples/bot-targeted-messages/Images/targeted_message.gif differ diff --git a/samples/bot-targeted-messages/M365Agent/.gitignore b/samples/bot-targeted-messages/M365Agent/.gitignore new file mode 100644 index 0000000000..c5cae9258c --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/.gitignore @@ -0,0 +1,10 @@ +# TeamsFx files +build +appPackage/build +env/.env.*.user +env/.env.local +appsettings.Development.json +.deployment + +# User-specific files +*.user diff --git a/samples/bot-targeted-messages/M365Agent/M365Agent.atkproj b/samples/bot-targeted-messages/M365Agent/M365Agent.atkproj new file mode 100644 index 0000000000..2b772824db --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/M365Agent.atkproj @@ -0,0 +1,12 @@ + + + + b069b3bd-f6bc-cc40-82ab-3fcc2ea50fdf + + + + + + + + \ No newline at end of file diff --git a/samples/bot-targeted-messages/M365Agent/appPackage/color.png b/samples/bot-targeted-messages/M365Agent/appPackage/color.png new file mode 100644 index 0000000000..01aa37e347 Binary files /dev/null and b/samples/bot-targeted-messages/M365Agent/appPackage/color.png differ diff --git a/samples/bot-targeted-messages/M365Agent/appPackage/manifest.json b/samples/bot-targeted-messages/M365Agent/appPackage/manifest.json new file mode 100644 index 0000000000..200e624bf0 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/appPackage/manifest.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "version": "1.0.7", + "manifestVersion": "1.24", + "id": "${{TEAMS_APP_ID}}", + "name": { + "short": "targeted-msg-${{APP_NAME_SUFFIX}}", + "full": "Targeted Message Bot" + }, + "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": "Bot for testing targeted messaging in Teams", + "full": "A testing bot to demonstrate and validate targeted messaging capabilities using Teams SDK methods for sending messages to specific users" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": [ "personal" ] + }, + { + "entityId": "about", + "scopes": [ "personal" ] + } + ], + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": [ "personal", "team", "groupChat" ], + "supportsFiles": false, + "isNotificationOnly": false, + "supportsVideo": true, + "supportsCalling": true, + "commandLists": [ + { + "scopes": [ "team", "groupChat", "personal" ], + "commands": [ + { + "title": "reminder-help", + "description": "Shows commands list" + } + + ] + } + ] + } + ], + "validDomains": [ + "${{BOT_DOMAIN}}" + ] +} diff --git a/samples/bot-targeted-messages/M365Agent/appPackage/outline.png b/samples/bot-targeted-messages/M365Agent/appPackage/outline.png new file mode 100644 index 0000000000..f7a4c86447 Binary files /dev/null and b/samples/bot-targeted-messages/M365Agent/appPackage/outline.png differ diff --git a/samples/bot-targeted-messages/M365Agent/env/.env.dev b/samples/bot-targeted-messages/M365Agent/env/.env.dev new file mode 100644 index 0000000000..df4f9da508 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/env/.env.dev @@ -0,0 +1,15 @@ +# This file includes environment variables that will be committed to git by default. + +# Built-in environment variables +TEAMSFX_ENV=dev +APP_NAME_SUFFIX=dev + +# Updating AZURE_SUBSCRIPTION_ID or AZURE_RESOURCE_GROUP_NAME after provision may also require an update to RESOURCE_SUFFIX, because some services require a globally unique name across subscriptions/resource groups. +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME= +RESOURCE_SUFFIX= + +# Generated during provision, you can also add your own variables. +BOT_ID= +TEAMS_APP_ID= +BOT_AZURE_APP_SERVICE_RESOURCE_ID= \ No newline at end of file diff --git a/samples/bot-targeted-messages/M365Agent/infra/azure.bicep b/samples/bot-targeted-messages/M365Agent/infra/azure.bicep new file mode 100644 index 0000000000..658e412a21 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/infra/azure.bicep @@ -0,0 +1,86 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +param webAppSKU string + +@maxLength(42) +param botDisplayName string + +param serverfarmsName string = resourceBaseName +param webAppName string = resourceBaseName +param identityName string = resourceBaseName +param location string = resourceGroup().location + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + location: location + name: identityName +} + +// Compute resources for your Web App +resource serverfarm 'Microsoft.Web/serverfarms@2021-02-01' = { + kind: 'app' + location: location + name: serverfarmsName + sku: { + name: webAppSKU + } +} + +// Web App that hosts your bot +resource webApp 'Microsoft.Web/sites@2021-02-01' = { + kind: 'app' + location: location + name: webAppName + properties: { + serverFarmId: serverfarm.id + httpsOnly: true + siteConfig: { + appSettings: [ + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + { + name: 'Teams__ClientId' + value: identity.properties.clientId + } + { + name: 'Teams__TenantId' + value: identity.properties.tenantId + } + { + name: 'Teams__BotType' + value: 'UserAssignedMsi' + } + ] + ftpsState: 'FtpsOnly' + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${identity.id}': {} + } + } +} + +// Register your web service as a bot with the Bot Framework +module azureBotRegistration './botRegistration/azurebot.bicep' = { + name: 'Azure-Bot-registration' + params: { + resourceBaseName: resourceBaseName + identityClientId: identity.properties.clientId + identityResourceId: identity.id + identityTenantId: identity.properties.tenantId + botAppDomain: webApp.properties.defaultHostName + botDisplayName: botDisplayName + } +} + +// The output will be persisted in .env.{envName}. Visit https://aka.ms/teamsfx-actions/arm-deploy for more details. +output BOT_AZURE_APP_SERVICE_RESOURCE_ID string = webApp.id +output BOT_DOMAIN string = webApp.properties.defaultHostName +output BOT_ID string = identity.properties.clientId +output BOT_TENANT_ID string = identity.properties.tenantId diff --git a/samples/bot-targeted-messages/M365Agent/infra/azure.parameters.json b/samples/bot-targeted-messages/M365Agent/infra/azure.parameters.json new file mode 100644 index 0000000000..46585361d0 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/infra/azure.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, + "webAppSKU": { + "value": "B1" + }, + "botDisplayName": { + "value": "TargetedMessage" + } + } + } \ No newline at end of file diff --git a/samples/bot-targeted-messages/M365Agent/infra/botRegistration/azurebot.bicep b/samples/bot-targeted-messages/M365Agent/infra/botRegistration/azurebot.bicep new file mode 100644 index 0000000000..a5a27b8fe4 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/infra/botRegistration/azurebot.bicep @@ -0,0 +1,42 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' +param identityResourceId string +param identityClientId string +param identityTenantId string +param botAppDomain string + +// Register your web service as a bot with the Bot Framework +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: 'https://${botAppDomain}/api/messages' + msaAppId: identityClientId + msaAppMSIResourceId: identityResourceId + msaAppTenantId:identityTenantId + msaAppType:'UserAssignedMSI' + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} diff --git a/samples/bot-targeted-messages/M365Agent/infra/botRegistration/readme.md b/samples/bot-targeted-messages/M365Agent/infra/botRegistration/readme.md new file mode 100644 index 0000000000..d5416243cd --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/infra/botRegistration/readme.md @@ -0,0 +1 @@ +The `azurebot.bicep` module is provided to help you create Azure Bot service when you don't use Azure to host your app. If you use Azure as infrastrcture for your app, `azure.bicep` under infra folder already leverages this module to create Azure Bot service for you. You don't need to deploy `azurebot.bicep` again. \ No newline at end of file diff --git a/samples/bot-targeted-messages/M365Agent/launchSettings.json b/samples/bot-targeted-messages/M365Agent/launchSettings.json new file mode 100644 index 0000000000..2af8ce7a8a --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/launchSettings.json @@ -0,0 +1,25 @@ +{ + "profiles": { + // Launch project within Microsoft 365 Agents Playground + "Microsoft 365 Agents Playground (browser)": { + "commandName": "Project", + "environmentVariables": { + "UPDATE_TEAMS_APP": "false", + "M365_AGENTS_PLAYGROUND_TARGET_SDK": "teams-ai-v2-dotnet" + }, + "launchTestTool": true, + "launchUrl": "http://localhost:56150", + }, + // Launch project within Teams + "Microsoft Teams (browser)": { + "commandName": "Project", + "launchUrl": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}", + }, + // Launch project within Teams without prepare app dependencies + "Microsoft Teams (browser) (skip update app)": { + "commandName": "Project", + "environmentVariables": { "UPDATE_TEAMS_APP": "false" }, + "launchUrl": "https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}" + }, + } +} \ No newline at end of file diff --git a/samples/bot-targeted-messages/M365Agent/m365agents.local.yml b/samples/bot-targeted-messages/M365Agent/m365agents.local.yml new file mode 100644 index 0000000000..0529e40ef0 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/m365agents.local.yml @@ -0,0 +1,77 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.11 + +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: TargetedMessage${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + # Create or reuse an existing Microsoft Entra application for bot. + - uses: aadApp/create + with: + # The Microsoft Entra application's display name + name: TargetedMessage${{APP_NAME_SUFFIX}} + generateClientSecret: true + generateServicePrincipal: true + signInAudience: AzureADMultipleOrgs + writeToEnvironmentFile: + # The Microsoft Entra application's client id created for bot. + clientId: BOT_ID + # The Microsoft Entra application's client secret created for bot. + clientSecret: SECRET_BOT_PASSWORD + # The Microsoft Entra application's object id created for bot. + objectId: BOT_OBJECT_ID + + # Generate runtime appsettings to JSON file + - uses: file/createOrUpdateJsonFile + with: + target: ../TargetedMessage/appsettings.Development.json + content: + Teams: + ClientId: ${{BOT_ID}} + ClientSecret: ${{SECRET_BOT_PASSWORD}} + TenantId: ${{TEAMS_APP_TENANT_ID}} + + # Create or update the bot registration on dev.botframework.com + - uses: botFramework/create + with: + botId: ${{BOT_ID}} + name: TargetedMessage + messagingEndpoint: ${{BOT_ENDPOINT}}/api/messages + description: "" + channels: + - name: msteams + + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + # Apply the Teams app manifest to an existing Teams app in + # Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip diff --git a/samples/bot-targeted-messages/M365Agent/m365agents.yml b/samples/bot-targeted-messages/M365Agent/m365agents.yml new file mode 100644 index 0000000000..208d3c02f9 --- /dev/null +++ b/samples/bot-targeted-messages/M365Agent/m365agents.yml @@ -0,0 +1,88 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.9/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.9 + +environmentFolderPath: ./env + +# Triggered when 'teamsapp provision' is executed +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: TargetedMessage${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: arm/deploy # Deploy given ARM templates parallelly. + with: + # AZURE_SUBSCRIPTION_ID is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select a subscription. + # Referencing other environment variables with empty values + # will skip the subscription selection prompt. + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select or create one + # resource group. + # Referencing other environment variables with empty values + # will skip the resource group selection prompt. + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure.bicep # Relative path to this file + # Relative path to this yaml file. + # Placeholders will be replaced with corresponding environment + # variable before ARM deployment. + parameters: ./infra/azure.parameters.json + # Required when deploying ARM template + deploymentName: Create-resources-for-bot + # Microsoft 365 Agents Toolkit will download this bicep CLI version from github for you, + # will use bicep CLI in PATH if you remove this config. + bicepCliVersion: v0.9.1 + + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in + # Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + +# Triggered when 'teamsapp deploy' is executed +deploy: + - uses: cli/runDotnetCommand + with: + args: publish --configuration Release --runtime win-x86 --self-contained + TargetedMessage.csproj + workingDirectory: ../TargetedMessage + # Deploy your application to Azure App Service using the zip deploy feature. + # For additional details, refer to https://aka.ms/zip-deploy-to-app-services. + - uses: azureAppService/zipDeploy + with: + # Deploy base folder + artifactFolder: bin/Release/net10.0/win-x86/publish + # The resource id of the cloud resource to be deployed to. + # This key will be generated by arm/deploy action automatically. + # You can replace it with your existing Azure Resource id + # or add it to your environment variable file. + resourceId: ${{BOT_AZURE_APP_SERVICE_RESOURCE_ID}} + workingDirectory: ../TargetedMessage diff --git a/samples/bot-targeted-messages/README.md b/samples/bot-targeted-messages/README.md new file mode 100644 index 0000000000..c55b1c379b --- /dev/null +++ b/samples/bot-targeted-messages/README.md @@ -0,0 +1,178 @@ +--- +page_type: sample +description: This sample bot demonstrates how to use targeted messaging in Microsoft Teams to send private messages to specific users within channels and group chats. It includes a personal reminder bot that showcases targeted message delivery. +products: +- office-teams +- office +- office-365 +languages: +- csharp +extensions: + contentType: samples + createdDate: "01/05/2026 05:00:17 PM" +urlFragment: officedev-microsoft-teams-samples-targeted-messages-csharp + +--- +## Targeted Messages in Microsoft Teams + +This sample demonstrates how to use targeted messaging in Microsoft Teams. Targeted messages are private messages that are only visible to a specific user within a channel or group chat conversation. The sample implements a personal reminder bot that sends reminders as targeted messages. + +![Targeted_messages](\Images\targeted_message.gif) + +## Included Features +* Bots +* Targeted Messaging +* Adaptive Cards + +## Prerequisites + +- Microsoft Teams is installed and you have an account (not a guest account). + +- [.NET SDK](https://dotnet.microsoft.com/download) version 10.0 + + ```bash + # determine dotnet version + dotnet --version + ``` +- Publicly addressable https url or tunnel such as [dev tunnel](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started?tabs=windows) or [ngrok](https://ngrok.com/) latest version +- [Microsoft 365 Agents Toolkit for Visual Studio](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/toolkit-v4/install-teams-toolkit-vs?pivots=visual-studio-v17-7) + +## Run the app (Using Microsoft 365 Agents Toolkit for Visual Studio) + +The simplest way to run this sample in Teams is to use Microsoft 365 Agents Toolkit for Visual Studio. +1. Install Visual Studio 2022 **Version 17.14 or higher** [Visual Studio](https://visualstudio.microsoft.com/downloads/) +1. Install Microsoft 365 Agents Toolkit for Visual Studio [Microsoft 365 Agents Toolkit extension](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/toolkit-v4/install-teams-toolkit-vs?pivots=visual-studio-v17-7) +1. In the debug dropdown menu of Visual Studio, select Dev Tunnels > Create A Tunnel (set authentication type to Public) or select an existing public dev tunnel. +1. Right-click the 'M365Agent' project in Solution Explorer and select **Microsoft 365 Agents Toolkit > Select Microsoft 365 Account** +1. Sign in to Microsoft 365 Agents Toolkit with a **Microsoft 365 work or school account** +1. Set `Startup Item` as `Microsoft Teams (browser)`. +1. Press F5, or select Debug > Start Debugging menu in Visual Studio to start your app +
![image](https://raw.githubusercontent.com/OfficeDev/TeamsFx/dev/docs/images/visualstudio/debug/debug-button.png) +1. In the opened web browser, select Add button to install the app in Teams +> If you do not have permission to upload custom apps (uploading), Microsoft 365 Agents Toolkit will recommend creating and using a Microsoft 365 Developer Program account - a free program to get your own dev environment sandbox that includes Teams. + +## Setup + +1. Register a new application in the [Microsoft Entra ID � App Registrations](https://go.microsoft.com/fwlink/?linkid=2083908) portal. + + + 1) Select **New Registration** and on the *register an application page*, set following values: + * Set **name** to your app name. + * Choose the **supported account types** (any account type will work) + * Leave **Redirect URI** empty. + * Choose **Register**. + + 2) On the overview page, copy and save the **Application (client) ID, Directory (tenant) ID**. You'll need those later when updating your Teams application manifest and in the appsettings.json. + + 3) Navigate to the **Certificates & secrets**. In the Client secrets section, click on "+ New client secret". Add a description (Name of the secret) for the secret and select "Never" for Expires. Click "Add". Once the client secret is created, copy its value, it need to be placed in the appsettings.json. + +2. Setup for Bot + - In Azure portal, create a [Azure Bot resource](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp%2Caadv2). + - Ensure that you've [enabled the Teams Channel](https://docs.microsoft.com/en-us/azure/bot-service/channel-connect-teams?view=azure-bot-service-4.0) + - While registering the bot, use `https:///api/messages` as the messaging endpoint. + +3. Run ngrok - point to port 5130 + + ```bash + ngrok http 5130 --host-header="localhost:5130" + ``` + + Alternatively, you can also use the `dev tunnels`. Please follow [Create and host a dev tunnel](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started?tabs=windows) and host the tunnel with anonymous user access command as shown below: + + ```bash + devtunnel host -p 5130 --allow-anonymous + ``` + +4. Setup for code + - Clone the repository + + ```bash + git clone https://github.com/OfficeDev/Microsoft-Teams-Samples.git + ``` + Run the bot from a terminal or from Visual Studio: + + A) From a terminal, navigate to `TargetedMessage` folder + + ```bash + # run the bot + dotnet run + ``` + + B) Or from Visual Studio + + - Launch Visual Studio + - File -> Open -> Project/Solution + - Navigate to `TargetedMessage` folder + - Select `TargetedMessage.csproj` file + - Press `F5` to run the project + +- Update the `appsettings.json` configuration file and replace with placeholder `ClientId` and `ClientSecret`. + +5. Setup Manifest for Teams +- __*This step is specific to Teams.*__ + - **Edit** the `manifest.json` contained in the ./appPackage folder to replace your ClientId (that was created when you registered your app registration earlier) *everywhere* you see the place holder string `TEAMS_APP_ID` and `BOT_ID` (depending on the scenario the Microsoft App Id may occur multiple times in the `manifest.json`) + - **Edit** the `manifest.json` for `validDomains` and replace `{{domain-name}}` with base Url of your domain. E.g. if you are using devtunnel it would be `https://1234.devtunnel.ms` then your domain-name will be `1234.devtunnel.ms` and if you are using dev tunnels then your domain will be like: `12345.devtunnels.ms`. + - **Zip** up the contents of the `appPackage` folder to create a `manifest.zip` (Make sure that zip file does not contains any subfolder otherwise you will get error while uploading your .zip package) + +- Upload the manifest.zip to Teams (in the Apps view click "Upload a custom app") + - Go to Microsoft Teams. From the lower left corner, select Apps + - From the lower left corner, choose Upload a custom App + - Go to your project directory, the ./appPackage folder, select the zip folder, and choose Open. + - Select Add in the pop-up dialog box. Your app is uploaded to Teams. + +## Running the sample + +**Using the Reminder Bot:** + +Once installed, you can use the following commands in any channel or group chat: + +**Set a Reminder:** +- `remind me in 5 minutes to check email` +- `remind me in 1 hour meeting starts` +- `remind me in 30 seconds test` +- `remind @John in 10 minutes review PR` + +**Supported Time Formats:** +- Seconds: `30 seconds`, `30 secs`, `30s` +- Minutes: `5 minutes`, `5 mins`, `5m` +- Hours: `1 hour`, `2 hrs`, `1h` + +**Manage Reminders:** +- `my-reminders` - View your active reminders +- `cancel-reminder [id]` - Cancel a specific reminder +- `reminder-help` - Show help information + +**How Targeted Messaging Works:** + +The bot uses the Teams SDK to send targeted messages. Key points: +- Set `isTargeted: true` when sending the message +- Set the `Recipient` property to specify who should see the message +- Works in both channels and group chats +- The message appears in the shared conversation but is only visible to the recipient + +**Help Message** +![help](/Images/1.help.png) + +**Set Reminder** +![help](/Images/2.set_reminder.png) + +**Reminder Received** +![help](/Images/3.reminder_received.png) + +**Check Actve Reminders** +![help](/Images/4.check_active_reminders.png) + +**Cancel Reminder** +![help](/Images/5.cancel_reminder.png) + +## Deploy the bot to Azure + +To learn more about deploying a bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete list of deployment instructions. + +## Further reading +- [Targeted Messages in Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +- [Send and receive messages with a bot](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages) +- [Adaptive Cards](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#adaptive-card) +- [Microsoft Teams SDK for .NET](https://microsoft.github.io/teams-sdk/welcome) + + \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage.slnLaunch.user b/samples/bot-targeted-messages/TargetedMessage.slnLaunch.user new file mode 100644 index 0000000000..29c65ec442 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage.slnLaunch.user @@ -0,0 +1,53 @@ +[ + { + "Name": "Microsoft 365 Agents Playground (browser)", + "Projects": [ + { + "Path": "M365Agent\\M365Agent.atkproj", + "Name": "M365Agent\\M365Agent.atkproj", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft 365 Agents Playground (browser)" + }, + { + "Path": "TargetedMessage\\TargetedMessage.csproj", + "Name": "TargetedMessage\\TargetedMessage.csproj", + "Action": "Start", + "DebugTarget": "Microsoft 365 Agents Playground" + } + ] + }, + { + "Name": "Microsoft Teams (browser)", + "Projects": [ + { + "Path": "M365Agent\\M365Agent.atkproj", + "Name": "M365Agent\\M365Agent.atkproj", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft Teams (browser)" + }, + { + "Path": "TargetedMessage\\TargetedMessage.csproj", + "Name": "TargetedMessage\\TargetedMessage.csproj", + "Action": "Start", + "DebugTarget": "Start Project" + } + ] + }, + { + "Name": "Microsoft Teams (browser) (skip update app)", + "Projects": [ + { + "Path": "M365Agent\\M365Agent.atkproj", + "Name": "M365Agent\\M365Agent.atkproj", + "Action": "StartWithoutDebugging", + "DebugTarget": "Microsoft Teams (browser) (skip update app)" + }, + { + "Path": "TargetedMessage\\TargetedMessage.csproj", + "Name": "TargetedMessage\\TargetedMessage.csproj", + "Action": "Start", + "DebugTarget": "Start Project" + } + ] + } +] \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage.slnx b/samples/bot-targeted-messages/TargetedMessage.slnx new file mode 100644 index 0000000000..eff6c86151 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/bot-targeted-messages/TargetedMessage/.gitignore b/samples/bot-targeted-messages/TargetedMessage/.gitignore new file mode 100644 index 0000000000..77c7154916 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/.gitignore @@ -0,0 +1,30 @@ +# TeamsFx files +build +appPackage/build +env/.env.*.user +env/.env.local +appsettings.Development.json +.deployment +appsettings.Playground.json + +# User-specific files +*.user + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Notification local store +.notification.localstore.json +.notification.playgroundstore.json + +# devTools +devTools/ \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage/Config.cs b/samples/bot-targeted-messages/TargetedMessage/Config.cs new file mode 100644 index 0000000000..7141c6c318 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/Config.cs @@ -0,0 +1,15 @@ +namespace TargetedMessage +{ + public class ConfigOptions + { + public TeamsConfigOptions Teams { get; set; } + } + + public class TeamsConfigOptions + { + public string BotType { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string TenantId { get; set; } + } +} \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage/Controllers/ReminderController.cs b/samples/bot-targeted-messages/TargetedMessage/Controllers/ReminderController.cs new file mode 100644 index 0000000000..0567aca499 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/Controllers/ReminderController.cs @@ -0,0 +1,827 @@ +using Microsoft.Teams.Api.Entities; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Annotations; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Api; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Collections.Concurrent; +using Microsoft.Teams.Apps.Activities.Invokes; +using Task = System.Threading.Tasks.Task; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Api.AdaptiveCards; +using MessageActivity = Microsoft.Teams.Api.Activities.MessageActivity; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Clients; +using Microsoft.Teams.Common.Http; +using Microsoft.Teams.Common; +using Microsoft.Teams.Cards; +using AdaptiveCard = Microsoft.Teams.Cards.AdaptiveCard; + +namespace TargetedMessage.Controllers +{ + /// + /// Personal Reminder Bot Controller + /// Demonstrates targeted messaging for private reminders in channels and group chats. + /// + /// Features: + /// - Set reminders for yourself or other members + /// - Parse natural language time expressions (e.g., "in 5 minutes") + /// - Edit reminder content before delivery + /// - Delete/cancel reminders + /// - Works in both Teams channels (L1/L2) and group chats + /// + [TeamsController] + public class ReminderController + { + private readonly Microsoft.Teams.Apps.App _app; + private readonly IConfiguration _configuration; + private readonly IHttpClient _httpClient; + + // Regex patterns for time parsing + private static readonly Regex TimeExpressionPattern = new( + @"in\s+(\d+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex TimeExpressionRemovalPattern = new( + @"in\s+\d+\s*(?:seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // In-memory storage for active reminders (use a database in production) + private static readonly ConcurrentDictionary _activeReminders = new(); + + public ReminderController(Microsoft.Teams.Apps.App app, IConfiguration configuration, IHttpClient httpClient) + { + _app = app; + _configuration = configuration; + _httpClient = httpClient; + } + + /// + /// Reminder information stored for each active reminder + /// + private class ReminderInfo + { + public required string Id { get; set; } + public required string ConversationId { get; set; } + public required string ServiceUrl { get; set; } + public required ConversationType ConversationType { get; set; } + public required string TargetUserId { get; set; } + public required string TargetUserName { get; set; } + public required string CreatorId { get; set; } + public required string CreatorName { get; set; } + public required string ReminderText { get; set; } + public required DateTime DueTime { get; set; } + public string? MessageId { get; set; } + public CancellationTokenSource? CancellationSource { get; set; } + } + + /// + /// Parsed reminder command result + /// + private class ParsedReminder + { + public string? TargetUserId { get; set; } + public string? TargetUserName { get; set; } + public bool IsSelfReminder { get; set; } + public TimeSpan Delay { get; set; } + public string ReminderText { get; set; } = ""; + public string? ErrorMessage { get; set; } + } + + private static string StripBotMentions(MessageActivity activity, string text) + { + if (string.IsNullOrEmpty(text)) return text; + if (activity.ChannelId != "msteams") return text; + + var entities = activity.Entities; + var botId = activity.Recipient?.Id; + + if (string.IsNullOrEmpty(botId) || entities == null) return text; + + foreach (var entity in entities) + { + if (entity.Type == "mention" && entity is MentionEntity mentionEntity) + { + if (mentionEntity.Mentioned?.Id == botId && !string.IsNullOrEmpty(mentionEntity.Text)) + { + text = text.Replace(mentionEntity.Text, "").Trim(); + } + } + } + + return text; + } + + /// + /// Extract mentioned user from activity entities (excluding bot mentions) + /// + private static (string? userId, string? userName) ExtractMentionedUser(MessageActivity activity) + { + var botId = activity.Recipient?.Id; + var entities = activity.Entities; + + if (entities == null) return (null, null); + + foreach (var entity in entities) + { + if (entity.Type == "mention" && entity is MentionEntity mentionEntity) + { + // Skip bot mentions + if (mentionEntity.Mentioned?.Id != botId) + { + return (mentionEntity.Mentioned?.Id, mentionEntity.Mentioned?.Name); + } + } + } + + return (null, null); + } + + /// + /// Parse time expressions like "in 5 minutes", "in 1 hour", "in 30 seconds" + /// + private static TimeSpan? ParseTimeExpression(string text) + { + var match = TimeExpressionPattern.Match(text.ToLower()); + + if (!match.Success) return null; + + var value = int.Parse(match.Groups[1].Value); + var unit = match.Groups[2].Value.ToLower(); + + // Match longer units first to avoid partial matches + if (unit.StartsWith("second") || unit.StartsWith("sec") || unit == "s") + return TimeSpan.FromSeconds(value); + if (unit.StartsWith("minute") || unit.StartsWith("min") || unit == "m") + return TimeSpan.FromMinutes(value); + if (unit.StartsWith("hour") || unit.StartsWith("hr") || unit == "h") + return TimeSpan.FromHours(value); + + return null; + } + + /// + /// Parse the remind command to extract target user, time, and reminder text + /// Format: remind [@user|me] in [time] [message] + /// Examples: + /// - remind me in 5 minutes to check email + /// - remind @John in 1 hour meeting starts + /// - remind me in 30 seconds test reminder + /// + private ParsedReminder ParseReminderCommand(MessageActivity activity, string commandText) + { + var result = new ParsedReminder(); + + // Remove "remind" prefix + var text = commandText.Trim(); + if (text.StartsWith("remind", StringComparison.OrdinalIgnoreCase)) + { + text = text[6..].Trim(); + } + + // Check for "me" (self-reminder) + if (text.StartsWith("me ", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("me,", StringComparison.OrdinalIgnoreCase) || + text.Equals("me", StringComparison.OrdinalIgnoreCase)) + { + result.IsSelfReminder = true; + result.TargetUserId = activity.From?.Id; + result.TargetUserName = activity.From?.Name ?? "You"; + text = text.Length > 2 ? text[2..].Trim().TrimStart(',').Trim() : ""; + } + else + { + // Check for @mention + var (userId, userName) = ExtractMentionedUser(activity); + if (userId != null) + { + result.TargetUserId = userId; + result.TargetUserName = userName ?? "User"; + // Remove the mention text from the command + foreach (var entity in activity.Entities ?? []) + { + if (entity is MentionEntity mention && mention.Mentioned?.Id == userId) + { + text = text.Replace(mention.Text ?? "", "").Trim(); + break; + } + } + } + else + { + // Default to self if no target specified + result.IsSelfReminder = true; + result.TargetUserId = activity.From?.Id; + result.TargetUserName = activity.From?.Name ?? "You"; + } + } + + // Parse time expression + var delay = ParseTimeExpression(text); + if (delay == null) + { + result.ErrorMessage = "Could not parse time. Use format like 'in 5 minutes', 'in 1 hour', or 'in 30 seconds'."; + return result; + } + result.Delay = delay.Value; + + // Extract reminder text (everything after the time expression) + var reminderText = TimeExpressionRemovalPattern.Replace(text, "").Trim(); + + // Clean up common words + reminderText = reminderText.TrimStart(',').Trim(); + if (reminderText.StartsWith("to ", StringComparison.OrdinalIgnoreCase)) + { + reminderText = reminderText[3..].Trim(); + } + if (reminderText.StartsWith("that ", StringComparison.OrdinalIgnoreCase)) + { + reminderText = reminderText[5..].Trim(); + } + + if (string.IsNullOrWhiteSpace(reminderText)) + { + reminderText = "You have a reminder!"; + } + + result.ReminderText = reminderText; + return result; + } + + /// + /// Convert string conversation type to enum + /// + private static ConversationType ParseConversationType(string? type) + { + return type?.ToLower() switch + { + "personal" => ConversationType.Personal, + "groupchat" => ConversationType.GroupChat, + "channel" => ConversationType.Channel, + _ => ConversationType.Channel + }; + } + + [Message] + public async Task OnReminderMessage(IContext context) + { + var (log, api, activity, client) = context; + + if (string.IsNullOrEmpty(activity.Text)) return; + + var text = StripBotMentions(activity, activity.Text ?? ""); + var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var cmd = parts.Length > 0 ? parts[0].ToLower() : ""; + + switch (cmd) + { + case "remind": + await HandleRemindCommand(context, text); + break; + + case "reminder-help": + await ShowReminderHelp(client); + break; + + case "my-reminders": + await ShowMyReminders(context); + break; + + case "cancel-reminder": + if (parts.Length > 1) + { + await CancelReminder(context, parts[1]); + } + else + { + await client.Send("Please specify the reminder ID to cancel. Use `my-reminders` to see your active reminders."); + } + break; + } + } + + private static async Task ShowReminderHelp(IContext.Client client) + { + await client.Send(string.Join("\n", new[] + { + "**Personal Reminder Bot - Help**", + "", + "**Set a Reminder:**", + "- `remind me in 5 minutes to check email`", + "- `remind me in 1 hour meeting starts`", + "- `remind me in 30 seconds test`", + "- `remind @John in 10 minutes review PR`", + "", + "**Supported Time Formats:**", + "- Seconds: `30 seconds`, `30 secs`, `30s`", + "- Minutes: `5 minutes`, `5 mins`, `5m`", + "- Hours: `1 hour`, `2 hrs`, `1h`", + "", + "**Manage Reminders:**", + "- `my-reminders` — View your active reminders", + "- `cancel-reminder [id]` — Cancel a specific reminder", + "", + "**Features:**", + "- Reminders are sent as **targeted messages** (only visible to recipient)", + "- Works in both **channels** and **group chats**", + "- Set reminders for yourself or mention others", + "- Edit or dismiss reminders via card buttons" + })); + } + + private async Task HandleRemindCommand(IContext context, string commandText) + { + var (log, api, activity, client) = context; + + // Parse the reminder command + var parsed = ParseReminderCommand(activity, commandText); + + if (parsed.ErrorMessage != null) + { + await client.Send($"{parsed.ErrorMessage}\n\nUse `reminder-help` for usage examples."); + return; + } + + if (string.IsNullOrEmpty(parsed.TargetUserId)) + { + await client.Send("Could not determine who to remind. Use `remind me` or mention someone like `remind @John`."); + return; + } + + // Create reminder info + var reminderId = Guid.NewGuid().ToString("N")[..8]; + var conversationId = activity.Conversation?.Id ?? ""; + var channelConversationId = conversationId.Split(';')[0]; + + var reminder = new ReminderInfo + { + Id = reminderId, + ConversationId = channelConversationId, + ServiceUrl = activity.ServiceUrl ?? "", + ConversationType = ParseConversationType(activity.Conversation?.Type), + TargetUserId = parsed.TargetUserId, + TargetUserName = parsed.TargetUserName ?? "User", + CreatorId = activity.From?.Id ?? "", + CreatorName = activity.From?.Name ?? "Someone", + ReminderText = parsed.ReminderText, + DueTime = DateTime.UtcNow.Add(parsed.Delay), + CancellationSource = new CancellationTokenSource() + }; + + _activeReminders[reminderId] = reminder; + + try + { + var confirmationCard = CreateReminderConfirmationCard(reminder, parsed.Delay); + confirmationCard.Recipient = activity.From; + + await _app.Send( + channelConversationId, + confirmationCard, + reminder.ConversationType, + activity.ServiceUrl ?? "", + isTargeted: true); + + log.Info($"[REMINDER] Created reminder {reminderId} for {parsed.TargetUserName} in {parsed.Delay.TotalSeconds} seconds"); + + // Schedule the reminder delivery as a fire-and-forget background task + // Don't pass the request cancellation token - it will cancel when the HTTP request ends + _ = DeliverReminderAsync(reminder); + } + catch (Microsoft.Teams.Common.Http.HttpException httpEx) + { + // Serialize the error response body properly + var errorDetails = "No details"; + try + { + if (httpEx.Body != null) + { + errorDetails = System.Text.Json.JsonSerializer.Serialize(httpEx.Body, new JsonSerializerOptions { WriteIndented = true }); + } + } + catch (Exception serEx) + { + errorDetails = $"Failed to serialize error: {serEx.Message}"; + } + + log.Error($"[REMINDER] HTTP error sending confirmation:\n" + + $" Status: {httpEx.StatusCode}\n" + + $" ConversationId: {channelConversationId}\n" + + $" ConversationType: {reminder.ConversationType.Value}\n" + + $" ServiceUrl: {activity.ServiceUrl}\n" + + $" Error Details: {errorDetails}"); + + // Don't use client.Send in catch block - it may fail with same HttpException + // Log the error only + _activeReminders.TryRemove(reminderId, out _); + } + catch (Exception ex) + { + log.Error($"[REMINDER] Error sending confirmation: {ex.GetType().Name} - {ex.Message}\n{ex.StackTrace}"); + _activeReminders.TryRemove(reminderId, out _); + } + } + + private async Task DeliverReminderAsync(ReminderInfo reminder) + { + try + { + // Only use the reminder's own cancellation source (not the request's token) + var cancellationToken = reminder.CancellationSource?.Token ?? CancellationToken.None; + + var delay = reminder.DueTime - DateTime.UtcNow; + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, cancellationToken); + } + + // Check if reminder was cancelled + if (!_activeReminders.ContainsKey(reminder.Id)) + { + Console.WriteLine($"[REMINDER] Reminder {reminder.Id} was cancelled, skipping delivery"); + return; + } + + // Create the reminder card with edit/dismiss options + var reminderCard = CreateReminderDeliveryCard(reminder); + + // Send targeted reminder to the recipient + // NOTE: Recipient IS REQUIRED when using isTargeted: true + reminderCard.Recipient = new Account { Id = reminder.TargetUserId, Name = reminder.TargetUserName }; + + await _app.Send( + reminder.ConversationId, + reminderCard, + reminder.ConversationType, + reminder.ServiceUrl, + isTargeted: true); + + Console.WriteLine($"[REMINDER] Delivered reminder {reminder.Id} to {reminder.TargetUserName}"); + + // Remove from active reminders + _activeReminders.TryRemove(reminder.Id, out _); + } + catch (OperationCanceledException) + { + Console.WriteLine($"[REMINDER] Reminder {reminder.Id} was cancelled"); + _activeReminders.TryRemove(reminder.Id, out _); + } + catch (Exception ex) + { + Console.WriteLine($"[REMINDER] Failed to deliver reminder {reminder.Id}: {ex.Message}"); + _activeReminders.TryRemove(reminder.Id, out _); + } + } + + private async Task ShowMyReminders(IContext context) + { + var (log, api, activity, client) = context; + var userId = activity.From?.Id; + + if (string.IsNullOrEmpty(userId)) + { + await client.Send("Could not determine your user ID."); + return; + } + + var myReminders = _activeReminders.Values + .Where(r => r.TargetUserId == userId || r.CreatorId == userId) + .OrderBy(r => r.DueTime) + .ToList(); + + if (myReminders.Count == 0) + { + await client.Send("You have no active reminders."); + return; + } + + var reminderList = string.Join("\n", myReminders.Select(r => + { + var timeLeft = r.DueTime - DateTime.UtcNow; + var timeStr = timeLeft.TotalSeconds > 0 + ? $"in {FormatTimeSpan(timeLeft)}" + : "overdue"; + var target = r.TargetUserId == userId ? "you" : r.TargetUserName; + return $"• **{r.Id}**: \"{r.ReminderText}\" for {target} ({timeStr})"; + })); + + await client.Send($"**Your Active Reminders:**\n\n{reminderList}\n\nUse `cancel-reminder [id]` to cancel a reminder."); + } + + private async Task CancelReminder(IContext context, string reminderId) + { + var (log, api, activity, client) = context; + var userId = activity.From?.Id; + + if (_activeReminders.TryGetValue(reminderId, out var reminder)) + { + // Only allow creator or target to cancel + if (reminder.CreatorId == userId || reminder.TargetUserId == userId) + { + reminder.CancellationSource?.Cancel(); + _activeReminders.TryRemove(reminderId, out _); + await client.Send($"Reminder **{reminderId}** has been cancelled."); + log.Info($"[REMINDER] Reminder {reminderId} cancelled by {activity.From?.Name}"); + } + else + { + await client.Send("You can only cancel reminders you created or are assigned to you."); + } + } + else + { + await client.Send($"Reminder **{reminderId}** not found or already completed."); + } + } + + private static string FormatTimeSpan(TimeSpan ts) + { + if (ts.TotalHours >= 1) + return $"{(int)ts.TotalHours}h {ts.Minutes}m"; + if (ts.TotalMinutes >= 1) + return $"{(int)ts.TotalMinutes}m {ts.Seconds}s"; + return $"{(int)ts.TotalSeconds}s"; + } + + private static MessageActivity CreateReminderConfirmationCard(ReminderInfo reminder, TimeSpan delay) + { + var targetDisplay = reminder.CreatorId == reminder.TargetUserId + ? "yourself" + : reminder.TargetUserName; + + // Build card using Teams SDK typed card builder API + var card = new AdaptiveCard + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", + Version = new Microsoft.Teams.Cards.Version("1.5"), + Body = + [ + new TextBlock("Reminder Set!") + { + Weight = TextWeight.Bolder, + Size = TextSize.Medium, + Color = TextColor.Good + }, + new FactSet + { + Facts = + [ + new Fact("Reminder:", reminder.ReminderText), + new Fact("For:", targetDisplay), + new Fact("In:", FormatTimeSpan(delay)), + new Fact("ID:", reminder.Id) + ] + }, + new TextBlock("The reminder will be sent as a private targeted message.") + { + Size = TextSize.Small, + IsSubtle = true, + Wrap = true + } + ], + Actions = + [ + new ExecuteAction + { + Title = "Cancel Reminder", + Data = new Union(new SubmitActionData + { + NonSchemaProperties = new Dictionary + { + { "action", "cancel_reminder" }, + { "reminderId", reminder.Id } + } + }) + } + ] + }; + + return new MessageActivity().AddAttachment(card); + } + + private static MessageActivity CreateReminderDeliveryCard(ReminderInfo reminder) + { + var fromDisplay = reminder.CreatorId == reminder.TargetUserId + ? "yourself" + : reminder.CreatorName; + + // Build card using Teams SDK typed card builder API + var card = new AdaptiveCard + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", + Version = new Microsoft.Teams.Cards.Version("1.5"), + Body = + [ + new TextBlock("Reminder") + { + Weight = TextWeight.Bolder, + Size = TextSize.Large, + Color = TextColor.Accent + }, + new TextBlock(reminder.ReminderText) + { + Wrap = true, + Size = TextSize.Medium + }, + new TextBlock($"Set by {fromDisplay}") + { + Size = TextSize.Small, + IsSubtle = true + } + ], + Actions = + [ + new ExecuteAction + { + Title = "Dismiss", + Data = new Union(new SubmitActionData + { + NonSchemaProperties = new Dictionary + { + { "action", "dismiss_reminder" }, + { "reminderId", reminder.Id } + } + }) + }, + new ExecuteAction + { + Title = "Snooze 5 min", + Data = new Union(new SubmitActionData + { + NonSchemaProperties = new Dictionary + { + { "action", "snooze_reminder" }, + { "reminderId", reminder.Id }, + { "reminderText", reminder.ReminderText }, + { "snoozeMinutes", "5" } + } + }) + } + ] + }; + + return new MessageActivity().AddAttachment(card); + } + + [Microsoft.Teams.Apps.Activities.Invokes.AdaptiveCard.Action] + public async Task OnReminderCardAction( + [Context] AdaptiveCards.ActionActivity activity, + [Context] IContext.Client client, + [Context] ApiClient api, + [Context] Microsoft.Teams.Common.Logging.ILogger log) + { + var data = activity.Value?.Action?.Data; + if (data == null) + { + return new ActionResponse.Message("No data specified") { StatusCode = 400 }; + } + + string? GetValue(string key) => data.TryGetValue(key, out var val) + ? (val is JsonElement el ? el.GetString() : val?.ToString()) + : null; + + var action = GetValue("action"); + var reminderId = GetValue("reminderId"); + + log.Info($"[REMINDER_ACTION] Action: {action}, ReminderId: {reminderId}"); + + switch (action) + { + case "cancel_reminder": + if (!string.IsNullOrEmpty(reminderId) && _activeReminders.TryRemove(reminderId, out var cancelled)) + { + cancelled.CancellationSource?.Cancel(); + log.Info($"[REMINDER] Cancelled reminder {reminderId}"); + return new ActionResponse.Message("Reminder cancelled!") { StatusCode = 200 }; + } + return new ActionResponse.Message("Reminder not found or already completed.") { StatusCode = 200 }; + + case "dismiss_reminder": + { + // Delete the reminder card + var activityId = activity.ReplyToId; + var conversationId = activity.Conversation?.Id?.Split(';')[0] ?? ""; + + if (!string.IsNullOrEmpty(activityId)) + { + try + { + await api.Conversations.Activities.DeleteAsync(conversationId, activityId, isTargeted: true); + log.Info($"[REMINDER] Dismissed reminder card {activityId}"); + } + catch (Exception ex) + { + log.Error($"[REMINDER] Failed to delete reminder card: {ex.Message}"); + } + } + return new ActionResponse.Message("Reminder dismissed!") { StatusCode = 200 }; + } + + case "snooze_reminder": + { + var reminderText = GetValue("reminderText") ?? "Snoozed reminder"; + var snoozeMinutesStr = GetValue("snoozeMinutes") ?? "5"; + var snoozeMinutes = int.TryParse(snoozeMinutesStr, out var mins) ? mins : 5; + + // Create a new reminder + var newReminderId = Guid.NewGuid().ToString("N")[..8]; + var conversationId = activity.Conversation?.Id?.Split(';')[0] ?? ""; + + var newReminder = new ReminderInfo + { + Id = newReminderId, + ConversationId = conversationId, + ServiceUrl = activity.ServiceUrl ?? "", + ConversationType = ParseConversationType(activity.Conversation?.Type), + TargetUserId = activity.From?.Id ?? "", + TargetUserName = activity.From?.Name ?? "User", + CreatorId = activity.From?.Id ?? "", + CreatorName = activity.From?.Name ?? "User", + ReminderText = reminderText, + DueTime = DateTime.UtcNow.AddMinutes(snoozeMinutes), + CancellationSource = new CancellationTokenSource() + }; + + _activeReminders[newReminderId] = newReminder; + + // Update the card to show snooze confirmation + var activityId = activity.ReplyToId; + if (!string.IsNullOrEmpty(activityId)) + { + var updatedCard = CreateSnoozeConfirmationCard(newReminder, snoozeMinutes); + // NOTE: Recipient IS REQUIRED when using isTargeted: true + updatedCard.Recipient = new Account { Id = activity.From?.Id, Name = activity.From?.Name }; + + try + { + await api.Conversations.Activities.UpdateAsync( + conversationId, + activityId, + updatedCard, + isTargeted: true); + } + catch (Exception ex) + { + log.Error($"[REMINDER] Failed to update card for snooze: {ex.Message}"); + } + } + + // Schedule delivery + _ = DeliverReminderAsync(newReminder); + + log.Info($"[REMINDER] Snoozed reminder, new ID: {newReminderId}, delay: {snoozeMinutes} minutes"); + return new ActionResponse.Message($"Snoozed for {snoozeMinutes} minutes!") { StatusCode = 200 }; + } + + default: + return new ActionResponse.Message("Unknown action") { StatusCode = 400 }; + } + } + + private static MessageActivity CreateSnoozeConfirmationCard(ReminderInfo reminder, int snoozeMinutes) + { + // Build card using Teams SDK typed card builder API + var card = new AdaptiveCard + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", + Version = new Microsoft.Teams.Cards.Version("1.5"), + Body = + [ + new TextBlock("Snoozed!") + { + Weight = TextWeight.Bolder, + Size = TextSize.Medium, + Color = TextColor.Accent + }, + new TextBlock(reminder.ReminderText) + { + Wrap = true + }, + new TextBlock($"Will remind you again in {snoozeMinutes} minutes.") + { + Size = TextSize.Small, + IsSubtle = true + } + ], + Actions = + [ + new ExecuteAction + { + Title = "Cancel", + Data = new Union(new SubmitActionData + { + NonSchemaProperties = new Dictionary + { + { "action", "cancel_reminder" }, + { "reminderId", reminder.Id } + } + }) + } + ] + }; + + return new MessageActivity().AddAttachment(card); + } + } +} diff --git a/samples/bot-targeted-messages/TargetedMessage/Program.cs b/samples/bot-targeted-messages/TargetedMessage/Program.cs new file mode 100644 index 0000000000..cf50496dfa --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/Program.cs @@ -0,0 +1,24 @@ +using TargetedMessage; +using TargetedMessage.Controllers; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Extensions; +using Microsoft.Teams.Common.Http; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); +var config = builder.Configuration.Get(); +var appBuilder = App.Builder(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var teamsApp = sp.GetRequiredService(); + return teamsApp.Client; +}); + +builder.AddTeams(appBuilder); + +var app = builder.Build(); +app.UseTeams(); +app.Run(); \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage/Properties/launchSettings.json b/samples/bot-targeted-messages/TargetedMessage/Properties/launchSettings.json new file mode 100644 index 0000000000..3572a7a03f --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/Properties/launchSettings.json @@ -0,0 +1,26 @@ +{ + "profiles": { + // Debug project within Microsoft 365 Agents Playground + "Microsoft 365 Agents Playground": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Playground", + "TEAMSFX_NOTIFICATION_STORE_FILENAME": ".notification.playgroundstore.json", + "UPDATE_TEAMS_APP": "false" + }, + "hotReloadProfile": "aspnetcore" + }, + // Debug project within Teams + "Start Project": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "hotReloadProfile": "aspnetcore" + }, + } +} \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage/TargetedMessage.csproj b/samples/bot-targeted-messages/TargetedMessage/TargetedMessage.csproj new file mode 100644 index 0000000000..c514e2bd8f --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/TargetedMessage.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + + + + + + + + + + + + + + + + + PreserveNewest + None + + + + PreserveNewest + None + + + \ No newline at end of file diff --git a/samples/bot-targeted-messages/TargetedMessage/appsettings.json b/samples/bot-targeted-messages/TargetedMessage/appsettings.json new file mode 100644 index 0000000000..9e3379db53 --- /dev/null +++ b/samples/bot-targeted-messages/TargetedMessage/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "AllowedHosts": "*", + "Teams": { + "ClientId": "", + "ClientSecret": "", + "BotType": "" + } +} \ No newline at end of file diff --git a/samples/bot-targeted-messages/assets/sample.json b/samples/bot-targeted-messages/assets/sample.json new file mode 100644 index 0000000000..2cdfc1ba34 --- /dev/null +++ b/samples/bot-targeted-messages/assets/sample.json @@ -0,0 +1,68 @@ +[ + { + "name": "officedev-microsoft-teams-samples-bot-targeted-messages-csharp", + "source": "officeDev", + "title": "Targeted Messages Bot", + "shortDescription": "This sample app demonstrates the use of targeted messages functionality in teams scope.", + "url": "https://github.com/OfficeDev/Microsoft-Teams-Samples/tree/main/samples/bot-targeted-messages/csharp", + "longDescription": [ + "This sample app demonstrates the use of targeted messages functionality in teams scope using Bot Framework." + ], + "creationDateTime": "2026-01-06", + "updateDateTime": "2026-01-06", + "products": [ + "Teams" + ], + "metadata": [ + { + "key": "TEAMS-SAMPLE-SOURCE", + "value": "OfficeDev" + }, + { + "key": "TEAMS-SERVER-LANGUAGE", + "value": "csharp" + }, + { + "key": "TEAMS-SERVER-PLATFORM", + "value": "netframework" + }, + { + "key": "TEAMS-FEATURES", + "value": "bot, adaptive card" + } + ], + "thumbnails": [ + { + "type": "image", + "order": 100, + "url": "https://raw.githubusercontent.com/OfficeDev/Microsoft-Teams-Samples/main/samples/bot-targeted-messages/csharp/Images/TargetedMessages.gif", + "alt": "Solution UX showing targeted messages Bot using Teams SDK" + } + ], + "authors": [ + { + "gitHubAccount": "OfficeDev", + "pictureUrl": "https://avatars.githubusercontent.com/u/6789362?s=200&v=4", + "name": "OfficeDev" + } + ], + "references": [ + { + "name": "Teams developer documentation", + "url": "https://aka.ms/TeamsPlatformDocs" + }, + { + "name": "Teams developer questions", + "url": "https://aka.ms/TeamsPlatformFeedback" + }, + { + "name": "Teams development videos from Microsoft", + "url": "https://aka.ms/sample-ref-teams-vids-from-microsoft" + }, + { + "name": "Teams development videos from the community", + "url": "https://aka.ms/sample-ref-teams-vids-from-community" + } + ] + } +] \ No newline at end of file