diff --git a/.github/workflows/build-complete-samples.yml b/.github/workflows/build-complete-samples.yml
index 8f1766993d..51819e6e61 100644
--- a/.github/workflows/build-complete-samples.yml
+++ b/.github/workflows/build-complete-samples.yml
@@ -496,6 +496,10 @@ jobs:
name: 'bot-auth0-adaptivecard'
version: '6.0.x'
+ - project_path: 'samples/bot-shared-channel-events/csharp/SharedChannelEvents/SharedChannelEvents.csproj'
+ name: 'bot-shared-channel-events'
+ version: '6.0.x'
+
- project_path: 'samples/graph-membership-change-notification/csharp/ChangeNotification/ChangeNotification.csproj'
name: 'graph-membership-change-notification'
version: '8.0.x'
diff --git a/README.md b/README.md
index f6851da28b..005b9acdfb 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,7 @@ The [Teams Toolkit](https://marketplace.visualstudio.com/items?itemName=TeamsDev
| 30 | Requirement Targeting Mutual Dependency | Microsoft M365 RT sample app in Node.js which specifies mutual-dependency relationships between app capabilities using elementRelationshipSet. | Advanced | | [View][RequirementTargetingMutualDependency#nodejs]  | | |
| 31 | Streaming Bot | This sample showcases the conversational streaming token scenario for teams bot in personal scope. | Advanced | [View][botstreaming#csharp] |[View][botstreaming#nodejs] | | |
| 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]  | [View][bot-auth0-adaptivecard#js]  | [View][bot-auth0-adaptivecard#python]  | | [View](/samples/bot-auth0-adaptivecard/csharp/demo-manifest/bot-auth0-adaptivecard.zip) |
-
+| 33 | 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]  | | | | |
#### Additional samples
| No. | Sample Name | Description | Level | .NET | JavaScript |
@@ -552,6 +552,7 @@ The [Teams Toolkit](https://marketplace.visualstudio.com/items?itemName=TeamsDev
[bot-conversation#cs]:samples/bot-conversation/csharp
[bot-auth0-adaptivecard#cs]:samples/bot-auth0-adaptivecard/csharp
+[bot-shared-channel-events#cs]:samples/bot-shared-channel-events/csharp
[bot-file-upload#cs]:samples/bot-file-upload/csharp
[bot-initiate-thread-in-channel#cs]:samples/bot-initiate-thread-in-channel/csharp
[bot-message-reaction#cs]:samples/bot-message-reaction/csharp
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/M365Agent.ttkproj b/samples/bot-shared-channel-events/csharp/M365Agent/M365Agent.ttkproj
new file mode 100644
index 0000000000..fa59ba10fc
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/M365Agent.ttkproj
@@ -0,0 +1,14 @@
+
+
+
+ 1694b6f5-8b63-41b6-8ba0-ca213f2a0449
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/aad.manifest.json b/samples/bot-shared-channel-events/csharp/M365Agent/aad.manifest.json
new file mode 100644
index 0000000000..89019b4134
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/aad.manifest.json
@@ -0,0 +1,32 @@
+{
+ "id": "${{AAD_APP_OBJECT_ID}}",
+ "appId": "${{AAD_APP_CLIENT_ID}}",
+ "name": "shared-channel-events",
+ "accessTokenAcceptedVersion": 2,
+ "signInAudience": "AzureADMyOrg",
+ "oauth2AllowIdTokenImplicitFlow": true,
+ "oauth2AllowImplicitFlow": true,
+ "optionalClaims": {
+ "idToken": [],
+ "accessToken": [
+ {
+ "name": "idtyp",
+ "source": null,
+ "essential": false,
+ "additionalProperties": []
+ }
+ ],
+ "saml2Token": []
+ },
+ "requiredResourceAccess": [
+ {
+ "resourceAppId": "Microsoft Graph",
+ "resourceAccess": [
+ {
+ "id": "User.Read",
+ "type": "Scope"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/color.png b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/color.png
new file mode 100644
index 0000000000..b8cf81afbe
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/color.png differ
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/manifest.json b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/manifest.json
new file mode 100644
index 0000000000..b7f56f3dc3
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/manifest.json
@@ -0,0 +1,60 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.19",
+ "version": "1.0.0",
+ "id": "${{TEAMS_APP_ID}}",
+ "developer": {
+ "name": "Microsoft",
+ "websiteUrl": "https://www.microsoft.com",
+ "privacyUrl": "https://www.microsoft.com/privacy",
+ "termsOfUseUrl": "https://www.microsoft.com/termsofuse"
+ },
+ "name": {
+ "short": "BotSharedChannelEvents",
+ "full": "This sample demonstrates how to build Sample for Shared Channel events."
+ },
+ "description": {
+ "short": "Teams Shared Channel events channelShared,channelUnshared with Bot Framework C#",
+ "full": "This sample demonstrates how to build a Microsoft Teams bot using Bot Framework SDK for .NET that responds to shared channel events. When a channel is shared or unshared across teams, or when members are added/removed in the context of a shared channel,"
+ },
+ "icons": {
+ "outline": "outline.png",
+ "color": "color.png"
+ },
+ "bots": [
+ {
+ "botId": "${{AAD_APP_CLIENT_ID}}",
+ "scopes": [
+ "team",
+ "personal"
+ ],
+ "isNotificationOnly": false
+ }
+ ],
+ "supportedChannelTypes": [
+ "sharedChannels"
+ ],
+ "accentColor": "#60A18E",
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": [
+ "${{BOT_DOMAIN}}",
+ "token.botframework.com"
+ ],
+ "webApplicationInfo": {
+ "id": "${{AAD_APP_CLIENT_ID}}",
+ "resource": "https://AnyString"
+ },
+ "authorization": {
+ "permissions": {
+ "resourceSpecific": [
+ {
+ "name": "ChannelMember.Read.Group",
+ "type": "Application"
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/outline.png b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/outline.png
new file mode 100644
index 0000000000..2c3bf6fa65
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/M365Agent/appPackage/outline.png differ
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/env/.env.local b/samples/bot-shared-channel-events/csharp/M365Agent/env/.env.local
new file mode 100644
index 0000000000..55ae3547f6
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/env/.env.local
@@ -0,0 +1,23 @@
+# This file includes environment variables that can be committed to git. It's gitignored by default because it represents your local development environment.
+
+# Built-in environment variables
+TEAMSFX_ENV=local
+APP_NAME_SUFFIX=local
+
+# Generated during provision, you can also add your own variables.
+TEAMS_APP_ID=
+RESOURCE_SUFFIX=
+AZURE_SUBSCRIPTION_ID=
+AZURE_RESOURCE_GROUP_NAME=
+AAD_APP_CLIENT_ID=
+AAD_APP_OBJECT_ID=
+AAD_APP_TENANT_ID=
+AAD_APP_OAUTH_AUTHORITY=
+AAD_APP_OAUTH_AUTHORITY_HOST=
+TEAMS_APP_TENANT_ID=
+MICROSOFT_APP_TYPE=SingleTenant
+MICROSOFT_APP_TENANT_ID=
+TEAMSFX_M365_USER_NAME=
+
+BOT_ENDPOINT=
+BOT_DOMAIN=
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/infra/azure.bicep b/samples/bot-shared-channel-events/csharp/M365Agent/infra/azure.bicep
new file mode 100644
index 0000000000..c3ce051b3d
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/infra/azure.bicep
@@ -0,0 +1,44 @@
+@maxLength(20)
+@minLength(4)
+@description('Used to generate names for all resources in this file')
+param resourceBaseName string
+
+@description('Required when create Azure Bot service')
+param botAadAppClientId string
+
+param botAppDomain string
+
+@maxLength(42)
+param botDisplayName string
+
+param botServiceName string = resourceBaseName
+param botServiceSku string = 'F0'
+param microsoftAppType string
+param microsoftAppTenantId 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: botAadAppClientId
+ msaAppType: microsoftAppType
+ msaAppTenantId: microsoftAppType == 'SingleTenant' ? microsoftAppTenantId : ''
+ }
+ 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-shared-channel-events/csharp/M365Agent/infra/azure.parameters.json b/samples/bot-shared-channel-events/csharp/M365Agent/infra/azure.parameters.json
new file mode 100644
index 0000000000..c16ead3342
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/infra/azure.parameters.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
+ "contentVersion": "1.0.0.0",
+ "parameters": {
+ "resourceBaseName": {
+ "value": "bot${{RESOURCE_SUFFIX}}"
+ },
+ "botAadAppClientId": {
+ "value": "${{AAD_APP_CLIENT_ID}}"
+ },
+ "botAppDomain": {
+ "value": "${{BOT_DOMAIN}}"
+ },
+ "botDisplayName": {
+ "value": "shared-channel-events"
+ },
+ "microsoftAppType": {
+ "value": "${{MICROSOFT_APP_TYPE}}"
+ },
+ "microsoftAppTenantId": {
+ "value": "${{MICROSOFT_APP_TENANT_ID}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/launchSettings.json b/samples/bot-shared-channel-events/csharp/M365Agent/launchSettings.json
new file mode 100644
index 0000000000..8c76c70d9e
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/launchSettings.json
@@ -0,0 +1,17 @@
+{
+ "profiles": {
+ // Debug 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-shared-channel-events/csharp/M365Agent/m365agents.local.yml b/samples/bot-shared-channel-events/csharp/M365Agent/m365agents.local.yml
new file mode 100644
index 0000000000..ab49691bf2
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/m365agents.local.yml
@@ -0,0 +1,91 @@
+# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.2/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.2
+
+additionalMetadata:
+ sampleTag: Microsoft-Teams-Samples:shared-channel-events-csharp
+
+provision:
+ - uses: aadApp/create # Creates a new Azure Active Directory (AAD) app to authenticate users if the environment variable that stores clientId is empty
+ with:
+ name: shared-channel-events-aad # Note: when you run aadApp/update, the AAD app name will be updated based on the definition in manifest. If you don't want to change the name, make sure the name in AAD manifest is the same with the name defined here.
+ generateClientSecret: true # If the value is false, the action will not generate client secret for you
+ signInAudience: "AzureADandPersonalMicrosoftAccount" # Multitenant
+ writeToEnvironmentFile: # Write the information of created resources into environment file for the specified environment variable(s).
+ clientId: AAD_APP_CLIENT_ID
+ clientSecret: SECRET_AAD_APP_CLIENT_SECRET # Environment variable that starts with `SECRET_` will be stored to the .env.{envName}.user environment file
+ objectId: AAD_APP_OBJECT_ID
+ tenantId: AAD_APP_TENANT_ID
+ authority: AAD_APP_OAUTH_AUTHORITY
+ authorityHost: AAD_APP_OAUTH_AUTHORITY_HOST
+
+ # Creates a Teams app
+ - uses: teamsApp/create
+ with:
+ # Teams app name
+ name: shared-channel-events-${{TEAMSFX_ENV}}
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ teamsAppId: TEAMS_APP_ID
+
+ - uses: script
+ with:
+ run:
+ # echo "::set-teamsfx-env MICROSOFT_APP_TYPE=MultiTenant";
+ echo "::set-teamsfx-env MICROSOFT_APP_TYPE=SingleTenant";
+ echo "::set-teamsfx-env MICROSOFT_APP_TENANT_ID=${{AAD_APP_TENANT_ID}}";
+
+ # Generate runtime appsettings to JSON file
+ - uses: file/createOrUpdateJsonFile
+ with:
+ target: ../SharedChannelEvents/appsettings.json
+ content:
+ MicrosoftAppId: ${{AAD_APP_CLIENT_ID}}
+ MicrosoftAppPassword: ${{SECRET_AAD_APP_CLIENT_SECRET}}
+ MicrosoftAppTenantId: ${{MICROSOFT_APP_TENANT_ID}}
+ MicrosoftAppType: ${{MICROSOFT_APP_TYPE}}
+
+ - uses: arm/deploy # Deploy given ARM templates parallelly.
+ with:
+ subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} # The AZURE_SUBSCRIPTION_ID is a built-in environment variable. TeamsFx will ask you select one subscription if its value is empty. You're free to reference other environment varialbe here, but TeamsFx will not ask you to select subscription if it's empty in this case.
+ resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} # The AZURE_RESOURCE_GROUP_NAME is a built-in environment variable. TeamsFx will ask you to select or create one resource group if its value is empty. You're free to reference other environment varialbe here, but TeamsFx will not ask you to select or create resource grouop if it's empty in this case.
+ templates:
+ - path: ./infra/azure.bicep
+ parameters: ./infra/azure.parameters.json
+ deploymentName: Create-resources-for-bot
+ bicepCliVersion: v0.9.1 # Teams Toolkit will download this bicep CLI version from github for you, will use bicep CLI in PATH if you remove this config.
+
+
+ - uses: aadApp/update # Apply the AAD manifest to an existing AAD app. Will use the object id in manifest file to determine which AAD app to update.
+ with:
+ manifestPath: ./aad.manifest.json # Relative path to teamsfx folder. Environment variables in manifest will be replaced before apply to AAD app
+ outputFilePath: ./build/aad.manifest.${{TEAMSFX_ENV}}.json
+
+ # 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
+ outputJsonPath: ./appPackage/build/manifest.${{TEAMSFX_ENV}}.json
+ # 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
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/M365Agent/m365agents.yml b/samples/bot-shared-channel-events/csharp/M365Agent/m365agents.yml
new file mode 100644
index 0000000000..a69b311e31
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/M365Agent/m365agents.yml
@@ -0,0 +1,9 @@
+# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.2/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.2
+
+additionalMetadata:
+ sampleTag: Microsoft-Teams-Samples:shared-channel-events-csharp
+
+environmentFolderPath: ./env
diff --git a/samples/bot-shared-channel-events/csharp/README.md b/samples/bot-shared-channel-events/csharp/README.md
new file mode 100644
index 0000000000..20f7a70b52
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/README.md
@@ -0,0 +1,177 @@
+---
+page_type: sample
+description: Microsoft Teams bot can receive transitive member add and remove events in shared channels.
+products:
+- office-teams
+- office
+- office-365
+languages:
+- csharp
+extensions:
+ contentType: samples
+ createdDate: "09/10/2025 23:35:25 PM"
+urlFragment: officedev-microsoft-teams-samples-bot-shared-channel-events-csharp
+---
+
+# Bot updates for handling transitive member add/remove events in Microsoft Teams shared channels
+
+This sample shows how to build a Microsoft Teams bot using the Bot Framework SDK that responds to transitive member changes in shared channels. When a member is added to or removed from a parent team that shares a channel, Teams automatically updates the membership of the shared channel. The bot receives these transitive member add and remove events and can process them to track membership changes, maintain rosters, or trigger custom workflows. This enables developers to extend shared channel scenarios by keeping their applications and services in sync with the latest membership state across teams and channels.
+
+The feature shown in this sample is currently available in public developer preview only.
+
+## Included Features
+* Bots
+* Adaptive Cards
+* RSC Permissions
+
+## Interaction with app
+
+
+
+## Prerequisites
+
+- [.NET Core SDK](https://dotnet.microsoft.com/download) version 6.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 or [Tunnel Relay](https://github.com/OfficeDev/microsoft-teams-tunnelrelay)
+- [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. In the debug dropdown menu of Visual Studio, select default startup project > **Microsoft Teams (browser)**
+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
+ 
+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
+> NOTE: The free ngrok plan will generate a new URL every time you run it, which requires you to update your Azure AD registration, the Teams app manifest, and the project configuration. A paid account with a permanent ngrok URL is recommended.
+
+1) Setup for Bot
+ - Register Azure AD application resource in Azure portal
+ - In Azure portal, create a [Azure Bot resource](https://docs.microsoft.com/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/azure/bot-service/channel-connect-teams?view=azure-bot-service-4.0)
+ - While registering the bot, use `https:///api/messages` as the messaging endpoint.
+
+## Configure Delegated Permissions in Azure AD App Registration
+
+ 1. Navigate to **API Permissions** in your app registration.
+ 2. Select **Microsoft Graph** → **Delegated permissions**.
+ 3. Add the following permission:
+ - `User.Read`
+ 4. Grant **Admin Consent** for the added permission.
+
+ **NOTE:** When you create your bot you will create an App ID and App password - make sure you keep these for later.
+
+2) Setup NGROK
+ Run ngrok - point to port 3978
+
+ ```bash
+ ngrok http 3978 --host-header="localhost:3978"
+ ```
+
+ 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 3978 --allow-anonymous
+ ```
+
+3) Setup for code
+- Clone the repository
+
+ ```bash
+ git clone https://github.com/OfficeDev/Microsoft-Teams-Samples.git
+
+- Navigate to `samples/bot-shared-channel-events/csharp`
+ - Modify the `/appsettings.json` and fill in the `{{ MicrosoftAppId }}`,`{{ MicrosoftAppPassword }}` with the values received while doing Microsoft Entra ID app registration in step 1.
+
+- Run the app from a terminal or from Visual Studio, choose option A or B.
+
+ A) From a terminal
+
+ ```bash
+ # run the app
+ dotnet run
+ ```
+
+ B) Or from Visual Studio
+
+ - Launch Visual Studio
+ - File -> Open -> Project/Solution
+ - Navigate to `SharedChannelEvents` folder
+ - Select `SharedChannelEvents.csproj` file
+ - Press `F5` to run the project
+
+4) Setup Manifest for Teams
+
+Modify the `manifest.json` in the `/appPackage` folder and replace the following details
+
+ - `<>` with your Microsoft Entra ID app registration id
+ - `<>` with base Url domain. E.g. if you are using ngrok it would be `https://1234.ngrok-free.app` then your domain-name will be `1234.ngrok-free.app` and if you are using dev tunnels then your domain will be like: `12345.devtunnels.ms`.
+ - Zip the contents of `appPackage` folder into a `manifest.zip`, and use the `manifest.zip` to deploy in app store
+ - - **Upload** the `manifest.zip` to Teams
+ - Select **Apps** from the left panel.
+ - Then select **Upload a custom app** from the lower right corner.
+ - Then select the `manifest.zip` file from `appPackage`.
+ - Install the App in Teams Channels.
+
+## Running the sample
+
+**Shared Channel Events:**
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 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
+
+- To be included once the release is completed.
+
+
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents.sln b/samples/bot-shared-channel-events/csharp/SharedChannelEvents.sln
new file mode 100644
index 0000000000..ebf73c60c3
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents.sln
@@ -0,0 +1,54 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedChannelEvents", "SharedChannelEvents\SharedChannelEvents.csproj", "{8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}"
+EndProject
+Project("{A9E3F50B-275E-4AF7-ADCE-8BE12D41E305}") = "M365Agent", "M365Agent\M365Agent.ttkproj", "{1694B6F5-8B63-41B6-8BA0-CA213F2A0449}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|x64.Build.0 = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Debug|x86.Build.0 = Debug|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|x64.ActiveCfg = Release|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|x64.Build.0 = Release|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|x86.ActiveCfg = Release|Any CPU
+ {8BE8A5FC-19D6-4195-8C2B-FAE3895AE6BC}.Release|x86.Build.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x64.Build.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x64.Deploy.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x86.Build.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Debug|x86.Deploy.0 = Debug|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|Any CPU.Deploy.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x64.ActiveCfg = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x64.Build.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x64.Deploy.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x86.ActiveCfg = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x86.Build.0 = Release|Any CPU
+ {1694B6F5-8B63-41B6-8BA0-CA213F2A0449}.Release|x86.Deploy.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/AdapterWithErrorHandler.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/AdapterWithErrorHandler.cs
new file mode 100644
index 0000000000..c92d68a064
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/AdapterWithErrorHandler.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+//
+// Generated with Bot Builder V4 SDK Template for Visual Studio CoreBot v4.14.0
+
+namespace SharedChannelEvents
+{
+ using Microsoft.Bot.Builder.Integration.AspNet.Core;
+ using Microsoft.Bot.Builder.TraceExtensions;
+ using Microsoft.Extensions.Configuration;
+ using Microsoft.Extensions.Logging;
+
+ public class AdapterWithErrorHandler : CloudAdapter
+ {
+ public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger)
+ : base(configuration, null, logger)
+ {
+ OnTurnError = async (turnContext, exception) =>
+ {
+ // Log any leaked exception from the application.
+ 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}");
+
+ // 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");
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Bots/SharedChannelDataBot.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Bots/SharedChannelDataBot.cs
new file mode 100644
index 0000000000..fddc457aaf
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Bots/SharedChannelDataBot.cs
@@ -0,0 +1,1819 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Bot.Builder;
+using Microsoft.Bot.Builder.Teams;
+using Microsoft.Bot.Schema;
+using Microsoft.Bot.Schema.Teams;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using SharedChannelEvents.Models;
+using AdaptiveCards;
+
+namespace SharedChannelEvents.Bots
+{
+ public class SharedChannelDataBot : TeamsActivityHandler
+ {
+ private readonly ILogger _logger;
+
+ public SharedChannelDataBot(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ // --- Handle channel share / unshare conversation updates ---
+ protected override async Task OnConversationUpdateActivityAsync(
+ ITurnContext turnContext,
+ CancellationToken cancellationToken)
+ {
+ // Always present on Teams activities
+ var tcd = turnContext.Activity.GetChannelData();
+ var eventType = tcd?.EventType?.ToLowerInvariant();
+
+ // Read extended shared-channel shape (safe even if fields are absent)
+ var extended = turnContext.Activity.GetChannelData();
+
+ // Also keep a raw JObject for logging / future-proof access
+ var raw = turnContext.Activity.ChannelData as JObject
+ ?? (turnContext.Activity.ChannelData != null
+ ? JObject.FromObject(turnContext.Activity.ChannelData)
+ : new JObject());
+
+ // Helpful baseline log
+ LoggerExtensions.LogInformation(_logger, "ConversationUpdate eventType={EventType}, channelId={ChannelId}, teamId={TeamId}",
+ eventType, tcd?.Channel?.Id, tcd?.Team?.Id);
+
+ switch (eventType)
+ {
+ case "channelshared":
+ {
+ var hostTeam = extended?.Team; // The channel's host team
+ var sharedWith = extended?.SharedWithTeams ?? new List();
+
+ LoggerExtensions.LogInformation(_logger, "ChannelShared: hostTeam={HostTeamId}, sharedWithCount={Count}",
+ hostTeam?.Id, sharedWith.Count);
+
+ // Enhanced debugging for team information
+ LoggerExtensions.LogInformation(_logger, "Host team info: Id={Id}, Name={Name}, AadGroupId={AadGroupId}",
+ hostTeam?.Id, hostTeam?.Name, hostTeam?.AadGroupId);
+
+ foreach (var team in sharedWith)
+ {
+ LoggerExtensions.LogInformation(_logger, "SharedWithTeam: id={Id}, name={Name}, aadGroupId={AadGroupId}, tenantId={TenantId}",
+ team.Id, team.Name, team.AadGroupId, team.TenantId);
+ }
+
+ // Try to extract team names from raw JSON if not available in structured data
+ var rawTeamNames = ExtractTeamNamesFromRaw(raw, "sharedWithTeams");
+ LoggerExtensions.LogInformation(_logger, "Raw team names extracted: {TeamNames}", string.Join(", ", rawTeamNames));
+
+ // Display formatted JSON in console
+ var channelSharedData = new
+ {
+ eventType = "channelShared",
+ channel = new { id = tcd?.Channel?.Id },
+ team = new
+ {
+ id = hostTeam?.Id,
+ aadGroupId = hostTeam?.AadGroupId,
+ tenantId = hostTeam?.TenantId,
+ name = hostTeam?.Name
+ },
+ sharedWithTeams = sharedWith.Select(t => new
+ {
+ id = t.Id,
+ aadGroupId = t.AadGroupId,
+ tenantId = t.TenantId,
+ name = t.Name
+ })
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(channelSharedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Channel shared\n{ChannelData}", formattedJson);
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ // Create adaptive card for each team the channel is shared with
+ foreach (var team in sharedWith)
+ {
+ string teamName = await ResolveTeamNameAsync(team, turnContext);
+
+ var channelSharedCard = CreateChannelSharedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ teamName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelSharedCard),
+ cancellationToken);
+ }
+
+ // If no teams to share with, send a generic message
+ if (sharedWith.Count == 0)
+ {
+ var channelSharedCard = CreateChannelSharedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Unknown Team"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelSharedCard),
+ cancellationToken);
+ }
+ break;
+ }
+
+ case "channelunshared":
+ {
+ var unsharedFrom = extended?.UnsharedFromTeams ?? new List();
+
+ LoggerExtensions.LogInformation(_logger, "ChannelUnshared: unsharedFromCount={Count}", unsharedFrom.Count);
+
+ foreach (var team in unsharedFrom)
+ {
+ LoggerExtensions.LogInformation(_logger, "UnsharedFromTeam: id={Id}, name={Name}, aadGroupId={AadGroupId}, tenantId={TenantId}",
+ team.Id, team.Name, team.AadGroupId, team.TenantId);
+ }
+
+ // Try to extract team names from raw JSON if not available in structured data
+ var rawTeamNames = ExtractTeamNamesFromRaw(raw, "unsharedFromTeams");
+ LoggerExtensions.LogInformation(_logger, "Raw team names extracted: {TeamNames}", string.Join(", ", rawTeamNames));
+
+ // Display formatted JSON in console
+ var channelUnsharedData = new
+ {
+ eventType = "channelUnshared",
+ channel = new { id = tcd?.Channel?.Id },
+ team = new
+ {
+ id = extended?.Team?.Id,
+ aadGroupId = extended?.Team?.AadGroupId,
+ tenantId = extended?.Team?.TenantId,
+ name = extended?.Team?.Name
+ },
+ unsharedFromTeams = unsharedFrom.Select(t => new
+ {
+ id = t.Id,
+ aadGroupId = t.AadGroupId,
+ tenantId = t.TenantId,
+ name = t.Name
+ })
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(channelUnsharedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Channel unshared\n{ChannelData}", formattedJson);
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ // Create adaptive card for each team the channel is unshared from
+ foreach (var team in unsharedFrom)
+ {
+ string teamName = await ResolveTeamNameAsync(team, turnContext);
+
+ var channelUnsharedCard = CreateChannelUnsharedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ teamName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelUnsharedCard),
+ cancellationToken);
+ }
+
+ // If no teams to unshare from, send a generic message
+ if (unsharedFrom.Count == 0)
+ {
+ var channelUnsharedCard = CreateChannelUnsharedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Unknown Team"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelUnsharedCard),
+ cancellationToken);
+ }
+ break;
+ }
+
+ case "channelmemberadded":
+ {
+ LoggerExtensions.LogInformation(_logger, "Channel member added event received");
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ // Try to extract member information from the activity
+ var memberNames = new List();
+
+ // Check if there's member information in MembersAdded
+ if (turnContext.Activity.MembersAdded != null && turnContext.Activity.MembersAdded.Count > 0)
+ {
+ foreach (var member in turnContext.Activity.MembersAdded)
+ {
+ if (member is TeamsChannelAccount teamsAccount)
+ {
+ string memberName = await GetMemberDisplayNameAsync(teamsAccount, turnContext);
+ memberNames.Add(memberName);
+ }
+ else
+ {
+ // Try to extract name from basic member info
+ string memberName = member.Name;
+ if (!string.IsNullOrEmpty(memberName))
+ {
+ memberNames.Add(memberName);
+ }
+ else if (!string.IsNullOrEmpty(member.Id))
+ {
+ // Create a user-friendly name from ID
+ var idParts = member.Id.Split(':');
+ if (idParts.Length > 1)
+ {
+ memberNames.Add($"User ({idParts[idParts.Length - 1].Substring(0, Math.Min(8, idParts[idParts.Length - 1].Length))})");
+ }
+ else
+ {
+ memberNames.Add($"User ({member.Id.Substring(0, Math.Min(8, member.Id.Length))})");
+ }
+ }
+ }
+ }
+ }
+
+ // Try to extract member names from raw channel data as fallback
+ if (memberNames.Count == 0)
+ {
+ try
+ {
+ var rawMembers = ExtractMemberNamesFromRaw(raw, "membersAdded");
+ memberNames.AddRange(rawMembers);
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to extract member names from raw data");
+ }
+ }
+
+ // Display formatted JSON in console
+ var memberAddedData = new
+ {
+ eventType = "channelMemberAdded",
+ channel = new { id = tcd?.Channel?.Id, name = extended?.Channel?.Name, type = extended?.Channel?.Type },
+ team = new { id = extended?.Team?.Id },
+ members = memberNames
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(memberAddedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Channel member added\n{ChannelData}", formattedJson);
+
+ // Create adaptive card for each member added or a summary card
+ if (memberNames.Count > 0)
+ {
+ foreach (string memberName in memberNames)
+ {
+ var memberAddedCard = CreateTeamMemberAddedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Channel Member Added",
+ memberName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberAddedCard),
+ cancellationToken);
+ }
+ }
+ else
+ {
+ // Fallback when no specific member names are available
+ var memberAddedCard = CreateTeamMemberAddedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Channel Member Added",
+ "A member has been added"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberAddedCard),
+ cancellationToken);
+ }
+
+ // Return early to avoid calling base method which causes permission errors
+ return;
+ }
+
+ case "channelmemberremoved":
+ {
+ LoggerExtensions.LogInformation(_logger, "Channel member removed event received");
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ // Try to extract member information from the activity
+ var memberNames = new List();
+
+ // Check if there's member information in MembersRemoved
+ if (turnContext.Activity.MembersRemoved != null && turnContext.Activity.MembersRemoved.Count > 0)
+ {
+ foreach (var member in turnContext.Activity.MembersRemoved)
+ {
+ if (member is TeamsChannelAccount teamsAccount)
+ {
+ string memberName = await GetMemberDisplayNameAsync(teamsAccount, turnContext);
+ memberNames.Add(memberName);
+ }
+ else
+ {
+ // Try to extract name from basic member info
+ string memberName = member.Name;
+ if (!string.IsNullOrEmpty(memberName))
+ {
+ memberNames.Add(memberName);
+ }
+ else if (!string.IsNullOrEmpty(member.Id))
+ {
+ // Create a user-friendly name from ID
+ var idParts = member.Id.Split(':');
+ if (idParts.Length > 1)
+ {
+ memberNames.Add($"User ({idParts[idParts.Length - 1].Substring(0, Math.Min(8, idParts[idParts.Length - 1].Length))})");
+ }
+ else
+ {
+ memberNames.Add($"User ({member.Id.Substring(0, Math.Min(8, member.Id.Length))})");
+ }
+ }
+ }
+ }
+ }
+
+ // Try to extract member names from raw channel data as fallback
+ if (memberNames.Count == 0)
+ {
+ try
+ {
+ var rawMembers = ExtractMemberNamesFromRaw(raw, "membersRemoved");
+ memberNames.AddRange(rawMembers);
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to extract member names from raw data");
+ }
+ }
+
+ // Display formatted JSON in console
+ var memberRemovedData = new
+ {
+ eventType = "channelMemberRemoved",
+ channel = new { id = tcd?.Channel?.Id, name = extended?.Channel?.Name, type = extended?.Channel?.Type },
+ team = new { id = extended?.Team?.Id },
+ members = memberNames
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(memberRemovedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Channel member removed\n{ChannelData}", formattedJson);
+
+ // Create adaptive card for each member removed or a summary card
+ if (memberNames.Count > 0)
+ {
+ foreach (string memberName in memberNames)
+ {
+ var memberRemovedCard = CreateTeamMemberRemovedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Channel Member Removed",
+ memberName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberRemovedCard),
+ cancellationToken);
+ }
+ }
+ else
+ {
+ // Fallback when no specific member names are available
+ var memberRemovedCard = CreateTeamMemberRemovedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Channel Member Removed",
+ "A member has been removed"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberRemovedCard),
+ cancellationToken);
+ }
+
+ // Return early to avoid calling base method which causes permission errors
+ return;
+ }
+
+ default:
+ // No-op; continue normal routing
+ break;
+ }
+
+ // (Optional) dump raw channelData for inspection when developing
+ var rawJson = raw.ToString(Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "channelData (raw): {Json}", rawJson);
+
+ // Additional debugging for team information structure
+ if (extended != null)
+ {
+ LoggerExtensions.LogInformation(_logger, "Extended channel data structure:");
+ LoggerExtensions.LogInformation(_logger, "- EventType: {EventType}", extended.EventType);
+ LoggerExtensions.LogInformation(_logger, "- Channel: {Channel}", extended.Channel != null ? JsonConvert.SerializeObject(extended.Channel) : "null");
+ LoggerExtensions.LogInformation(_logger, "- Team: {Team}", extended.Team != null ? JsonConvert.SerializeObject(extended.Team) : "null");
+ LoggerExtensions.LogInformation(_logger, "- SharedWithTeams count: {Count}", extended.SharedWithTeams?.Count ?? 0);
+ LoggerExtensions.LogInformation(_logger, "- UnsharedFromTeams count: {Count}", extended.UnsharedFromTeams?.Count ?? 0);
+
+ if (extended.SharedWithTeams != null)
+ {
+ for (int i = 0; i < extended.SharedWithTeams.Count; i++)
+ {
+ var team = extended.SharedWithTeams[i];
+ LoggerExtensions.LogInformation(_logger, "- SharedWithTeams[{Index}]: Id={Id}, Name='{Name}', AadGroupId={AadGroupId}",
+ i, team.Id, team.Name, team.AadGroupId);
+ }
+ }
+
+ if (extended.UnsharedFromTeams != null)
+ {
+ for (int i = 0; i < extended.UnsharedFromTeams.Count; i++)
+ {
+ var team = extended.UnsharedFromTeams[i];
+ LoggerExtensions.LogInformation(_logger, "- UnsharedFromTeams[{Index}]: Id={Id}, Name='{Name}', AadGroupId={AadGroupId}",
+ i, team.Id, team.Name, team.AadGroupId);
+ }
+ }
+ }
+
+ await base.OnConversationUpdateActivityAsync(turnContext, cancellationToken);
+ }
+
+ // --- Membership add/remove: where did this member come from in a shared channel? ---
+ protected override async Task OnTeamsMembersAddedAsync(
+ IList membersAdded,
+ TeamInfo teamInfo,
+ ITurnContext turnContext,
+ CancellationToken cancellationToken)
+ {
+ var extended = turnContext.Activity.GetChannelData();
+ var source = extended?.MembershipSource
+ ?? (turnContext.Activity.ChannelData as JObject)?["membershipSource"]?.ToObject();
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ foreach (var member in membersAdded)
+ {
+ // Enhanced member name resolution using async method
+ string memberName = await GetMemberDisplayNameAsync(member, turnContext);
+
+ // Create and send adaptive card for member added
+ var memberAddedCard = CreateTeamMemberAddedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Member Added",
+ memberName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberAddedCard),
+ cancellationToken);
+
+ LoggerExtensions.LogInformation(_logger, "Team member added: {MemberName} (ID: {MemberId}) to channel {ChannelName} ({ChannelType})",
+ memberName, member.Id, channelInfo.ChannelName, channelInfo.ChannelType);
+ }
+
+ if (source != null)
+ {
+ LoggerExtensions.LogInformation(_logger, "MemberAdded via {SourceType} ({MembershipType}). SourceId={Id}, TeamGroupId={TeamGroupId}, TenantId={TenantId}",
+ source.SourceType, source.MembershipType, source.Id, source.TeamGroupId, source.TenantId);
+
+ // Display formatted JSON in console for member added
+ var memberAddedData = new
+ {
+ eventType = "teamMemberAdded",
+ membershipSource = new
+ {
+ sourceType = source.SourceType,
+ id = source.Id,
+ membershipType = source.MembershipType,
+ teamGroupId = source.TeamGroupId,
+ tenantId = source.TenantId
+ }
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(memberAddedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Member added in shared channel ({MembershipType} via {SourceType})\n{MemberData}",
+ source.MembershipType, source.SourceType, formattedJson);
+ }
+
+ await base.OnTeamsMembersAddedAsync(membersAdded, teamInfo, turnContext, cancellationToken);
+ }
+
+ protected override async Task OnTeamsMembersRemovedAsync(
+ IList membersRemoved,
+ TeamInfo teamInfo,
+ ITurnContext turnContext,
+ CancellationToken cancellationToken)
+ {
+ var source = turnContext.Activity.GetChannelData()?.MembershipSource
+ ?? (turnContext.Activity.ChannelData as JObject)?["membershipSource"]?.ToObject();
+
+ // Get channel information
+ var channelInfo = GetChannelInfo(turnContext);
+
+ foreach (var member in membersRemoved)
+ {
+ // Enhanced member name resolution using async method
+ string memberName = await GetMemberDisplayNameAsync(member, turnContext);
+
+ // Create and send adaptive card for member removed
+ var memberRemovedCard = CreateTeamMemberRemovedAdaptiveCard(
+ channelInfo.ChannelName,
+ channelInfo.ChannelType,
+ "Member Removed",
+ memberName
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(memberRemovedCard),
+ cancellationToken);
+
+ LoggerExtensions.LogInformation(_logger, "Team member removed: {MemberName} (ID: {MemberId}) from channel {ChannelName} ({ChannelType})",
+ memberName, member.Id, channelInfo.ChannelName, channelInfo.ChannelType);
+ }
+
+ if (source != null)
+ {
+ LoggerExtensions.LogInformation(_logger, "MemberRemoved via {SourceType} ({MembershipType}). SourceId={Id}, TeamGroupId={TeamGroupId}, TenantId={TenantId}",
+ source.SourceType, source.MembershipType, source.Id, source.TeamGroupId, source.TenantId);
+
+ // Display formatted JSON in console for member removed
+ var memberRemovedData = new
+ {
+ eventType = "teamMemberRemoved",
+ membershipSource = new
+ {
+ sourceType = source.SourceType,
+ id = source.Id,
+ membershipType = source.MembershipType,
+ teamGroupId = source.TeamGroupId,
+ tenantId = source.TenantId
+ }
+ };
+
+ var formattedJson = JsonConvert.SerializeObject(memberRemovedData, Formatting.Indented);
+ LoggerExtensions.LogInformation(_logger, "Member removed from shared channel ({MembershipType} via {SourceType})\n{MemberData}",
+ source.MembershipType, source.SourceType, formattedJson);
+ }
+
+ await base.OnTeamsMembersRemovedAsync(membersRemoved, teamInfo, turnContext, cancellationToken);
+ }
+
+ // --- Handle Teams channel creation and deletion events ---
+ protected override async Task OnTeamsChannelCreatedAsync(ChannelInfo channelInfo, TeamInfo teamInfo, ITurnContext turnContext, CancellationToken cancellationToken)
+ {
+ LoggerExtensions.LogInformation(_logger, "Channel created: {ChannelName} (ID: {ChannelId}) in team {TeamName} (ID: {TeamId})",
+ channelInfo.Name, channelInfo.Id, teamInfo?.Name, teamInfo?.Id);
+
+ // Determine channel type based on channelInfo properties
+ string channelType = GetChannelTypeFromChannelInfo(channelInfo);
+
+ // Create and send adaptive card for channel created
+ var channelCreatedCard = CreateChannelCreatedAdaptiveCard(
+ channelInfo.Name ?? "Unknown Channel",
+ channelType,
+ teamInfo?.Name ?? "Unknown Team"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelCreatedCard),
+ cancellationToken);
+
+ await base.OnTeamsChannelCreatedAsync(channelInfo, teamInfo, turnContext, cancellationToken);
+ }
+
+ protected override async Task OnTeamsChannelDeletedAsync(ChannelInfo channelInfo, TeamInfo teamInfo, ITurnContext turnContext, CancellationToken cancellationToken)
+ {
+ LoggerExtensions.LogInformation(_logger, "Channel deleted: {ChannelName} (ID: {ChannelId}) from team {TeamName} (ID: {TeamId})",
+ channelInfo.Name, channelInfo.Id, teamInfo?.Name, teamInfo?.Id);
+
+ // Determine channel type based on channelInfo properties
+ string channelType = GetChannelTypeFromChannelInfo(channelInfo);
+
+ // Create and send adaptive card for channel deleted
+ var channelDeletedCard = CreateChannelDeletedAdaptiveCard(
+ channelInfo.Name ?? "Unknown Channel",
+ channelType,
+ teamInfo?.Name ?? "Unknown Team"
+ );
+
+ await turnContext.SendActivityAsync(
+ MessageFactory.Attachment(channelDeletedCard),
+ cancellationToken);
+
+ await base.OnTeamsChannelDeletedAsync(channelInfo, teamInfo, turnContext, cancellationToken);
+ }
+
+ ///
+ /// Determines the channel type from ChannelInfo properties
+ ///
+ private string GetChannelTypeFromChannelInfo(ChannelInfo channelInfo)
+ {
+ if (channelInfo == null)
+ return "Unknown";
+
+ try
+ {
+ // Try to determine channel type based on available information
+ // This is a best-effort approach using available properties
+
+ // Check if it's a private channel
+ // Private channels in Teams typically have different naming patterns or properties
+ if (!string.IsNullOrEmpty(channelInfo.Name))
+ {
+ // Private channels often have specific indicators
+ // This is a simplified approach - you might need to enhance based on your Teams setup
+
+ // For now, we'll use a heuristic approach
+ // You can enhance this by checking additional properties or making API calls
+
+ // Default logic for demonstration
+ return "Standard"; // Most channels are standard by default
+ }
+
+ return "Standard";
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to determine channel type for channel: {ChannelName}", channelInfo?.Name);
+ return "Unknown";
+ }
+ }
+
+ ///
+ /// Extracts team names from raw JSON when structured data doesn't contain names
+ ///
+ private List ExtractTeamNamesFromRaw(JObject raw, string arrayPropertyName)
+ {
+ var teamNames = new List();
+
+ try
+ {
+ var teamsArray = raw[arrayPropertyName] as JArray;
+ if (teamsArray != null)
+ {
+ foreach (var teamToken in teamsArray)
+ {
+ if (teamToken is JObject teamObj)
+ {
+ // Try different possible property names for team name
+ string teamName = teamObj["name"]?.ToString()
+ ?? teamObj["displayName"]?.ToString()
+ ?? teamObj["teamName"]?.ToString()
+ ?? teamObj["Name"]?.ToString()
+ ?? teamObj["DisplayName"]?.ToString();
+
+ if (!string.IsNullOrEmpty(teamName))
+ {
+ teamNames.Add(teamName);
+ }
+ else
+ {
+ // If no name, try to create a meaningful identifier
+ string id = teamObj["id"]?.ToString() ?? teamObj["Id"]?.ToString();
+ if (!string.IsNullOrEmpty(id))
+ {
+ // Use consistent formatting with ResolveTeamNameAsync
+ if (id.Contains(":"))
+ {
+ var parts = id.Split(':');
+ if (parts.Length > 1)
+ {
+ var lastPart = parts.Last();
+ if (parts[0] == "19" && lastPart.Length >= 6)
+ {
+ teamNames.Add($"Team-{lastPart.Substring(0, Math.Min(6, lastPart.Length))}");
+ }
+ else
+ {
+ teamNames.Add($"Team-{lastPart.Substring(0, Math.Min(8, lastPart.Length))}");
+ }
+ }
+ }
+ else
+ {
+ teamNames.Add($"Team-{id.Substring(0, Math.Min(8, id.Length))}");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to extract team names from raw JSON");
+ }
+
+ return teamNames;
+ }
+
+ ///
+ /// Extracts member names from raw JSON when structured data doesn't contain names
+ ///
+ private List ExtractMemberNamesFromRaw(JObject raw, string arrayPropertyName)
+ {
+ var memberNames = new List();
+
+ try
+ {
+ var membersArray = raw[arrayPropertyName] as JArray;
+ if (membersArray != null)
+ {
+ foreach (var memberToken in membersArray)
+ {
+ if (memberToken is JObject memberObj)
+ {
+ // Try different possible property names for member name
+ string memberName = memberObj["name"]?.ToString()
+ ?? memberObj["displayName"]?.ToString()
+ ?? memberObj["givenName"]?.ToString()
+ ?? memberObj["Name"]?.ToString()
+ ?? memberObj["DisplayName"]?.ToString()
+ ?? memberObj["GivenName"]?.ToString();
+
+ if (!string.IsNullOrEmpty(memberName))
+ {
+ memberNames.Add(memberName);
+ }
+ else
+ {
+ // Try to construct name from firstName and lastName
+ string firstName = memberObj["givenName"]?.ToString() ?? memberObj["GivenName"]?.ToString();
+ string lastName = memberObj["surname"]?.ToString() ?? memberObj["Surname"]?.ToString();
+
+ if (!string.IsNullOrEmpty(firstName) && !string.IsNullOrEmpty(lastName))
+ {
+ memberNames.Add($"{firstName} {lastName}");
+ }
+ else if (!string.IsNullOrEmpty(firstName))
+ {
+ memberNames.Add(firstName);
+ }
+ else
+ {
+ // Try to extract from email or userPrincipalName
+ string email = memberObj["email"]?.ToString()
+ ?? memberObj["userPrincipalName"]?.ToString()
+ ?? memberObj["Email"]?.ToString()
+ ?? memberObj["UserPrincipalName"]?.ToString();
+
+ if (!string.IsNullOrEmpty(email))
+ {
+ var emailParts = email.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ // Make it more presentable
+ var words = nameFromEmail.Split(' ');
+ var capitalizedName = string.Join(" ", words.Select(word =>
+ string.IsNullOrEmpty(word) ? word : char.ToUpper(word[0]) + word.Substring(1).ToLower()));
+ memberNames.Add(capitalizedName);
+ }
+ }
+ else
+ {
+ // Final fallback - use part of member ID if available
+ string id = memberObj["id"]?.ToString() ?? memberObj["Id"]?.ToString();
+ if (!string.IsNullOrEmpty(id))
+ {
+ var idParts = id.Split(':');
+ if (idParts.Length > 1)
+ {
+ var lastPart = idParts[idParts.Length - 1];
+ memberNames.Add($"User ({lastPart.Substring(0, Math.Min(8, lastPart.Length))})");
+ }
+ else
+ {
+ memberNames.Add($"User ({id.Substring(0, Math.Min(8, id.Length))})");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to extract member names from raw JSON");
+ }
+
+ return memberNames;
+ }
+
+ ///
+ /// Creates a formatted message for team sharing/unsharing events
+ ///
+ private async Task CreateTeamMessageAsync(List teams, List rawTeamNames, string action, string indicator, ITurnContext turnContext)
+ {
+ var teamNames = new List();
+
+ // First, try to get names from structured data
+ foreach (var team in teams)
+ {
+ string resolvedName = await ResolveTeamNameAsync(team, turnContext);
+ teamNames.Add(resolvedName);
+ }
+
+ // If no meaningful names from structured data, use raw extracted names
+ if (teamNames.All(name => name.StartsWith("Team-") || name.StartsWith("Team (") || name == "Unknown Team") && rawTeamNames.Count > 0)
+ {
+ // Only use raw names if they seem more meaningful than our generated ones
+ var meaningfulRawNames = rawTeamNames.Where(name =>
+ !string.IsNullOrEmpty(name) &&
+ !name.StartsWith("Team-") &&
+ !name.StartsWith("Team (") &&
+ name != "Unknown Team").ToList();
+
+ if (meaningfulRawNames.Count > 0)
+ {
+ teamNames = meaningfulRawNames;
+ }
+ }
+
+ // Create the message
+ if (teamNames.Count == 0)
+ {
+ return $"{indicator} Channel {action} team(s).";
+ }
+ else if (teamNames.Count == 1)
+ {
+ return $"{indicator} Channel {action} {teamNames[0]}.";
+ }
+ else if (teamNames.Count <= 3)
+ {
+ return $"{indicator} Channel {action} {string.Join(", ", teamNames)}.";
+ }
+ else
+ {
+ var firstThree = string.Join(", ", teamNames.Take(3));
+ return $"{indicator} Channel {action} {firstThree} and {teamNames.Count - 3} more team(s).";
+ }
+ }
+
+ ///
+ /// Resolves team name using multiple strategies including Teams API calls
+ ///
+ private async Task ResolveTeamNameAsync(TeamInfoEx team, ITurnContext turnContext)
+ {
+ // Strategy 1: Use the name if already available
+ if (!string.IsNullOrEmpty(team.Name))
+ {
+ return team.Name;
+ }
+
+ // Strategy 2: Try to get team details using Teams API
+ try
+ {
+ if (!string.IsNullOrEmpty(team.Id))
+ {
+ var teamDetails = await TeamsInfo.GetTeamDetailsAsync(turnContext, team.Id);
+ if (teamDetails != null && !string.IsNullOrEmpty(teamDetails.Name))
+ {
+ LoggerExtensions.LogInformation(_logger, "Retrieved team name via Teams API: {TeamName} for ID {TeamId}",
+ teamDetails.Name, team.Id);
+ return teamDetails.Name;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to get team details via Teams API for team ID: {TeamId}", team.Id);
+ }
+
+ // Strategy 3: Try using AAD Group ID if available
+ try
+ {
+ if (!string.IsNullOrEmpty(team.AadGroupId))
+ {
+ // Note: This would require Microsoft Graph API access
+ // For now, we'll create a placeholder that could be extended
+ LoggerExtensions.LogInformation(_logger, "Could fetch team name using AAD Group ID: {AadGroupId}", team.AadGroupId);
+ }
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to resolve team name via AAD Group ID: {AadGroupId}", team.AadGroupId);
+ }
+
+ // Strategy 4: Create a user-friendly identifier
+ if (!string.IsNullOrEmpty(team.Id))
+ {
+ // Check if it's a Teams ID format and extract meaningful part
+ if (team.Id.Contains(":"))
+ {
+ var parts = team.Id.Split(':');
+ if (parts.Length > 1)
+ {
+ var lastPart = parts.Last();
+
+ // For Teams conversation IDs, show a more meaningful format
+ if (parts[0] == "19" && lastPart.Length >= 6)
+ {
+ // This looks like a Teams conversation/channel ID
+ return $"Team-{lastPart.Substring(0, Math.Min(6, lastPart.Length))}";
+ }
+ else if (lastPart.Length > 8)
+ {
+ return $"Team-{lastPart.Substring(0, 8)}";
+ }
+ else
+ {
+ return $"Team-{lastPart}";
+ }
+ }
+ }
+ else
+ {
+ // Regular ID - use first 8 characters
+ var shortId = team.Id.Substring(0, Math.Min(8, team.Id.Length));
+ return $"Team-{shortId}";
+ }
+ }
+
+ // Final fallback
+ return "Unknown Team";
+ }
+
+ ///
+ /// Enhanced method to get member display name with multiple fallback options
+ ///
+ private async Task GetMemberDisplayNameAsync(TeamsChannelAccount member, ITurnContext turnContext)
+ {
+ // Strategy 1: Try multiple name properties in order of preference
+ if (!string.IsNullOrEmpty(member.Name))
+ return member.Name;
+
+ if (!string.IsNullOrEmpty(member.GivenName) && !string.IsNullOrEmpty(member.Surname))
+ return $"{member.GivenName} {member.Surname}";
+
+ if (!string.IsNullOrEmpty(member.GivenName))
+ return member.GivenName;
+
+ // Strategy 2: Try to get member details using Teams API
+ try
+ {
+ if (!string.IsNullOrEmpty(member.Id))
+ {
+ // Try to get team member details if we have team context
+ var teamId = turnContext.Activity.GetChannelData()?.Team?.Id;
+ if (!string.IsNullOrEmpty(teamId))
+ {
+ var teamMember = await TeamsInfo.GetTeamMemberAsync(turnContext, teamId, member.Id, cancellationToken: default);
+ if (teamMember != null)
+ {
+ if (!string.IsNullOrEmpty(teamMember.Name))
+ {
+ LoggerExtensions.LogInformation(_logger, "Retrieved member name via Teams API: {MemberName} for ID {MemberId}",
+ teamMember.Name, member.Id);
+ return teamMember.Name;
+ }
+
+ if (!string.IsNullOrEmpty(teamMember.GivenName) && !string.IsNullOrEmpty(teamMember.Surname))
+ {
+ var fullName = $"{teamMember.GivenName} {teamMember.Surname}";
+ LoggerExtensions.LogInformation(_logger, "Retrieved member name via Teams API: {MemberName} for ID {MemberId}",
+ fullName, member.Id);
+ return fullName;
+ }
+
+ if (!string.IsNullOrEmpty(teamMember.GivenName))
+ {
+ LoggerExtensions.LogInformation(_logger, "Retrieved member given name via Teams API: {MemberName} for ID {MemberId}",
+ teamMember.GivenName, member.Id);
+ return teamMember.GivenName;
+ }
+
+ // Try UserPrincipalName from team member if available
+ if (!string.IsNullOrEmpty(teamMember.UserPrincipalName))
+ {
+ var emailParts = teamMember.UserPrincipalName.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ LoggerExtensions.LogInformation(_logger, "Extracted member name from UPN via Teams API: {MemberName} for ID {MemberId}",
+ nameFromEmail, member.Id);
+ return nameFromEmail;
+ }
+ }
+ }
+ }
+
+ // Try to get general member info if team context is not available
+ var memberInfo = await TeamsInfo.GetMemberAsync(turnContext, member.Id, cancellationToken: default);
+ if (memberInfo != null)
+ {
+ if (!string.IsNullOrEmpty(memberInfo.Name))
+ {
+ LoggerExtensions.LogInformation(_logger, "Retrieved member name via Teams GetMember API: {MemberName} for ID {MemberId}",
+ memberInfo.Name, member.Id);
+ return memberInfo.Name;
+ }
+
+ if (!string.IsNullOrEmpty(memberInfo.GivenName) && !string.IsNullOrEmpty(memberInfo.Surname))
+ {
+ var fullName = $"{memberInfo.GivenName} {memberInfo.Surname}";
+ LoggerExtensions.LogInformation(_logger, "Retrieved member name via Teams GetMember API: {MemberName} for ID {MemberId}",
+ fullName, member.Id);
+ return fullName;
+ }
+
+ if (!string.IsNullOrEmpty(memberInfo.GivenName))
+ {
+ LoggerExtensions.LogInformation(_logger, "Retrieved member given name via Teams GetMember API: {MemberName} for ID {MemberId}",
+ memberInfo.GivenName, member.Id);
+ return memberInfo.GivenName;
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to get member details via Teams API for member ID: {MemberId}", member.Id);
+ }
+
+ // Strategy 3: Try UserPrincipalName from original member
+ if (!string.IsNullOrEmpty(member.UserPrincipalName))
+ {
+ // Extract name from email address
+ var emailParts = member.UserPrincipalName.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ // Make it more presentable
+ var words = nameFromEmail.Split(' ');
+ var capitalizedName = string.Join(" ", words.Select(word =>
+ string.IsNullOrEmpty(word) ? word : char.ToUpper(word[0]) + word.Substring(1).ToLower()));
+ return capitalizedName;
+ }
+ }
+
+ // Strategy 4: Try Email from original member
+ if (!string.IsNullOrEmpty(member.Email))
+ {
+ // Extract name from email address
+ var emailParts = member.Email.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ // Make it more presentable
+ var words = nameFromEmail.Split(' ');
+ var capitalizedName = string.Join(" ", words.Select(word =>
+ string.IsNullOrEmpty(word) ? word : char.ToUpper(word[0]) + word.Substring(1).ToLower()));
+ return capitalizedName;
+ }
+ }
+
+ // Strategy 5: Try to extract from AAD Object ID if available
+ if (!string.IsNullOrEmpty(member.AadObjectId))
+ {
+ // Note: This could be enhanced to use Microsoft Graph API to get user details
+ LoggerExtensions.LogInformation(_logger, "Member has AAD Object ID: {AadObjectId}, could fetch user details via Graph API", member.AadObjectId);
+ // For now, return a more user-friendly identifier
+ return $"User (AAD: {member.AadObjectId.Substring(0, Math.Min(8, member.AadObjectId.Length))})";
+ }
+
+ // Final fallback - use part of member ID if available, but make it more user-friendly
+ if (!string.IsNullOrEmpty(member.Id))
+ {
+ // Extract meaningful part from member ID
+ var idParts = member.Id.Split(':');
+ if (idParts.Length > 1)
+ {
+ var lastPart = idParts[idParts.Length - 1];
+ return $"User ({lastPart.Substring(0, Math.Min(8, lastPart.Length))})";
+ }
+ else
+ {
+ return $"User ({member.Id.Substring(0, Math.Min(8, member.Id.Length))})";
+ }
+ }
+
+ return "Unknown Member";
+ }
+
+ ///
+ /// Synchronous version for backwards compatibility
+ ///
+ private string GetMemberDisplayName(TeamsChannelAccount member)
+ {
+ // Try multiple name properties in order of preference
+ if (!string.IsNullOrEmpty(member.Name))
+ return member.Name;
+
+ if (!string.IsNullOrEmpty(member.GivenName) && !string.IsNullOrEmpty(member.Surname))
+ return $"{member.GivenName} {member.Surname}";
+
+ if (!string.IsNullOrEmpty(member.GivenName))
+ return member.GivenName;
+
+ if (!string.IsNullOrEmpty(member.UserPrincipalName))
+ {
+ // Extract name from email address
+ var emailParts = member.UserPrincipalName.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ // Make it more presentable
+ var words = nameFromEmail.Split(' ');
+ var capitalizedName = string.Join(" ", words.Select(word =>
+ string.IsNullOrEmpty(word) ? word : char.ToUpper(word[0]) + word.Substring(1).ToLower()));
+ return capitalizedName;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(member.Email))
+ {
+ // Extract name from email address
+ var emailParts = member.Email.Split('@');
+ if (emailParts.Length > 0)
+ {
+ var nameFromEmail = emailParts[0].Replace(".", " ");
+ // Make it more presentable
+ var words = nameFromEmail.Split(' ');
+ var capitalizedName = string.Join(" ", words.Select(word =>
+ string.IsNullOrEmpty(word) ? word : char.ToUpper(word[0]) + word.Substring(1).ToLower()));
+ return capitalizedName;
+ }
+ }
+
+ // Final fallback - use part of member ID if available
+ if (!string.IsNullOrEmpty(member.Id))
+ {
+ // Extract meaningful part from member ID
+ var idParts = member.Id.Split(':');
+ if (idParts.Length > 1)
+ {
+ var lastPart = idParts[idParts.Length - 1];
+ return $"User ({lastPart.Substring(0, Math.Min(8, lastPart.Length))})";
+ }
+ }
+
+ return "Unknown Member";
+ }
+
+ ///
+ /// Gets channel information including name and type
+ ///
+ private (string ChannelName, string ChannelType) GetChannelInfo(ITurnContext turnContext)
+ {
+ try
+ {
+ // Get Teams channel data
+ var tcd = turnContext.Activity.GetChannelData();
+ var extended = turnContext.Activity.GetChannelData();
+
+ // Determine channel name
+ string channelName = "Unknown Channel";
+
+ // Try to get channel name from various sources
+ if (!string.IsNullOrEmpty(extended?.Channel?.Name))
+ {
+ channelName = extended.Channel.Name;
+ }
+ else if (!string.IsNullOrEmpty(tcd?.Channel?.Name))
+ {
+ channelName = tcd.Channel.Name;
+ }
+ else
+ {
+ // Extract channel name from conversation ID if possible
+ var conversationId = turnContext.Activity.Conversation?.Id;
+ if (!string.IsNullOrEmpty(conversationId))
+ {
+ // Teams conversation IDs often contain channel information
+ if (conversationId.Contains("@thread"))
+ {
+ channelName = "Teams Channel";
+ }
+ else
+ {
+ channelName = "Teams Conversation";
+ }
+ }
+ }
+
+ // Determine channel type
+ string channelType = "Standard";
+
+ // Check if it's a shared channel
+ if (extended?.SharedWithTeams?.Count > 0 || extended?.UnsharedFromTeams?.Count > 0)
+ {
+ channelType = "Shared";
+ }
+ else if (!string.IsNullOrEmpty(extended?.EventType))
+ {
+ // If we have shared channel data structure, it's likely a shared channel
+ channelType = "Shared";
+ }
+ else
+ {
+ // Check conversation type
+ var conversationType = turnContext.Activity.Conversation?.ConversationType;
+ if (conversationType == "channel")
+ {
+ channelType = "Standard";
+ }
+ else if (conversationType == "groupChat")
+ {
+ channelType = "Group Chat";
+ }
+ }
+
+ return (channelName, channelType);
+ }
+ catch (Exception ex)
+ {
+ LoggerExtensions.LogWarning(_logger, ex, "Failed to get channel info");
+ return ("Unknown Channel", "Unknown");
+ }
+ }
+
+ ///
+ /// Creates an adaptive card for team member added event with green styling
+ ///
+ private Attachment CreateTeamMemberAddedAdaptiveCard(string channelName, string channelType, string eventType, string memberName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Good,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "+",
+ Size = AdaptiveTextSize.Large,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Team Member Added",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Medium,
+ Color = AdaptiveTextColor.Good
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Channel Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Channel Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Event Type:",
+ Value = eventType
+ },
+ new AdaptiveFact
+ {
+ Title = "Member:",
+ Value = $"{memberName} has been added"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ ///
+ /// Creates an adaptive card for team member removed event with red styling
+ ///
+ private Attachment CreateTeamMemberRemovedAdaptiveCard(string channelName, string channelType, string eventType, string memberName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Attention,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "-",
+ Size = AdaptiveTextSize.Large,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Team Member Removed",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Medium,
+ Color = AdaptiveTextColor.Attention
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Channel Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Channel Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Event Type:",
+ Value = eventType
+ },
+ new AdaptiveFact
+ {
+ Title = "Member:",
+ Value = $"{memberName} has been removed"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ ///
+ /// Creates an adaptive card for channel shared event with blue/info styling
+ ///
+ private Attachment CreateChannelSharedAdaptiveCard(string channelName, string channelType, string teamName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Accent,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Size = AdaptiveTextSize.Large,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Channel Shared With Team",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Large,
+ Color = AdaptiveTextColor.Dark
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Channel Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Channel Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Team Name:",
+ Value = teamName
+ },
+ new AdaptiveFact
+ {
+ Title = "Event:",
+ Value = "Channel has been shared with this team"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ ///
+ /// Creates an adaptive card for channel unshared event with warning styling
+ ///
+ private Attachment CreateChannelUnsharedAdaptiveCard(string channelName, string channelType, string teamName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Warning,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Size = AdaptiveTextSize.Large,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Channel Unshared From Team",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Large,
+ Color = AdaptiveTextColor.Dark
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Channel Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Channel Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Team Name:",
+ Value = teamName
+ },
+ new AdaptiveFact
+ {
+ Title = "Event:",
+ Value = "Channel sharing has been removed from this team"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ ///
+ /// Creates an adaptive card for channel created event with good/success styling
+ ///
+ private Attachment CreateChannelCreatedAdaptiveCard(string channelName, string channelType, string teamName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Good,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "+",
+ Size = AdaptiveTextSize.ExtraLarge,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center,
+ Color = AdaptiveTextColor.Dark,
+ Weight = AdaptiveTextWeight.Bolder
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Channel Created",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Large,
+ Color = AdaptiveTextColor.Dark
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Team:",
+ Value = teamName
+ },
+ new AdaptiveFact
+ {
+ Title = "Event:",
+ Value = "New channel has been created"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ ///
+ /// Creates an adaptive card for channel deleted event with attention/warning styling
+ ///
+ private Attachment CreateChannelDeletedAdaptiveCard(string channelName, string channelType, string teamName)
+ {
+ var card = new AdaptiveCard(new AdaptiveSchemaVersion("1.4"))
+ {
+ Body = new List
+ {
+ new AdaptiveContainer
+ {
+ Style = AdaptiveContainerStyle.Attention,
+ Items = new List
+ {
+ new AdaptiveColumnSet
+ {
+ Columns = new List
+ {
+ new AdaptiveColumn
+ {
+ Width = "auto",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "X",
+ Size = AdaptiveTextSize.ExtraLarge,
+ HorizontalAlignment = AdaptiveHorizontalAlignment.Center,
+ Color = AdaptiveTextColor.Dark,
+ Weight = AdaptiveTextWeight.Bolder
+ }
+ }
+ },
+ new AdaptiveColumn
+ {
+ Width = "stretch",
+ Items = new List
+ {
+ new AdaptiveTextBlock
+ {
+ Text = "Channel Deleted",
+ Weight = AdaptiveTextWeight.Bolder,
+ Size = AdaptiveTextSize.Large,
+ Color = AdaptiveTextColor.Dark
+ }
+ }
+ }
+ }
+ },
+ new AdaptiveFactSet
+ {
+ Facts = new List
+ {
+ new AdaptiveFact
+ {
+ Title = "Name:",
+ Value = channelName
+ },
+ new AdaptiveFact
+ {
+ Title = "Type:",
+ Value = channelType
+ },
+ new AdaptiveFact
+ {
+ Title = "Team:",
+ Value = teamName
+ },
+ new AdaptiveFact
+ {
+ Title = "Event:",
+ Value = "Channel has been deleted"
+ },
+ new AdaptiveFact
+ {
+ Title = "Time:",
+ Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ }
+ }
+ },
+ Spacing = AdaptiveSpacing.Medium
+ }
+ }
+ };
+
+ return new Attachment
+ {
+ ContentType = AdaptiveCard.ContentType,
+ Content = card
+ };
+ }
+
+ // Keep basic echo so you can poke the bot
+ protected override async Task OnMessageActivityAsync(
+ ITurnContext turnContext,
+ CancellationToken cancellationToken)
+ {
+ await turnContext.SendActivityAsync(
+ MessageFactory.Text($"Echo: {turnContext.Activity.Text}"),
+ cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Controllers/BotController.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Controllers/BotController.cs
new file mode 100644
index 0000000000..0fe4a90a96
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Controllers/BotController.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+// Generated with Bot Builder V4 SDK Template for Visual Studio EchoBot v4.21.0
+
+namespace SharedChannelEvents.Controllers
+{
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Bot.Builder;
+ using Microsoft.Bot.Builder.Integration.AspNet.Core;
+ using System.Threading.Tasks;
+
+ // This ASP Controller is created to handle a request. Dependency Injection will provide the Adapter and IBot
+ // implementation at runtime. Multiple different IBot implementations running at different endpoints can be
+ // achieved by specifying a more specific type for the bot constructor argument.
+ [Route("api/messages")]
+ [ApiController]
+ public class BotController : ControllerBase
+ {
+ private readonly IBotFrameworkHttpAdapter Adapter;
+ private readonly IBot Bot;
+
+ public BotController(IBotFrameworkHttpAdapter adapter, IBot bot)
+ {
+ Adapter = adapter;
+ Bot = bot;
+ }
+
+ [HttpPost, HttpGet]
+ public async Task PostAsync()
+ {
+ // Delegate the processing of the HTTP POST to the adapter.
+ // The adapter will invoke the bot.
+ await Adapter.ProcessAsync(Request, Response, Bot);
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/1.Install.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/1.Install.png
new file mode 100644
index 0000000000..014a0da7ba
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/1.Install.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/10.Member_Added.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/10.Member_Added.png
new file mode 100644
index 0000000000..4e216b51fe
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/10.Member_Added.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/11.Removing_Member_From_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/11.Removing_Member_From_Channel.png
new file mode 100644
index 0000000000..40df81c8a9
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/11.Removing_Member_From_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/12.Member_Removed.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/12.Member_Removed.png
new file mode 100644
index 0000000000..ed9d57315d
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/12.Member_Removed.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/13.Share_Channel_Team.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/13.Share_Channel_Team.png
new file mode 100644
index 0000000000..a9a9cc8bb2
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/13.Share_Channel_Team.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/14.Select_Team_For_Sharing.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/14.Select_Team_For_Sharing.png
new file mode 100644
index 0000000000..bbf03bea4a
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/14.Select_Team_For_Sharing.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/15.Channel_Shared_With_Team.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/15.Channel_Shared_With_Team.png
new file mode 100644
index 0000000000..30aad35eda
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/15.Channel_Shared_With_Team.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/16.Channel_UnShare_With_Team.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/16.Channel_UnShare_With_Team.png
new file mode 100644
index 0000000000..decbea6088
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/16.Channel_UnShare_With_Team.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/17.Removing_Channel_From_Team.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/17.Removing_Channel_From_Team.png
new file mode 100644
index 0000000000..2ee26f89d0
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/17.Removing_Channel_From_Team.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/18.Channel_UnShared_Notification.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/18.Channel_UnShared_Notification.png
new file mode 100644
index 0000000000..87e33a5829
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/18.Channel_UnShared_Notification.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/2.Select_Teams.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/2.Select_Teams.png
new file mode 100644
index 0000000000..050c678209
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/2.Select_Teams.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/3.Add_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/3.Add_Channel.png
new file mode 100644
index 0000000000..42c0b41b34
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/3.Add_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/4.Create_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/4.Create_Channel.png
new file mode 100644
index 0000000000..9b3a165552
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/4.Create_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/5.SelectMember_For_ShareChannel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/5.SelectMember_For_ShareChannel.png
new file mode 100644
index 0000000000..bb0680631d
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/5.SelectMember_For_ShareChannel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/6.Manage_Created_Shared_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/6.Manage_Created_Shared_Channel.png
new file mode 100644
index 0000000000..e9ccf68700
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/6.Manage_Created_Shared_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/7.Select_Apps_Tab.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/7.Select_Apps_Tab.png
new file mode 100644
index 0000000000..1ce33b3c60
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/7.Select_Apps_Tab.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/8.Select_App_Add_To_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/8.Select_App_Add_To_Channel.png
new file mode 100644
index 0000000000..c5bb2dbfd7
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/8.Select_App_Add_To_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/9.Add_Member_To_Channel.png b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/9.Add_Member_To_Channel.png
new file mode 100644
index 0000000000..8954314fd6
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/9.Add_Member_To_Channel.png differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/SharedChannelEvents.gif b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/SharedChannelEvents.gif
new file mode 100644
index 0000000000..4de072f2af
Binary files /dev/null and b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/SharedChannelEvents.gif differ
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Models/SharedChannelModel.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Models/SharedChannelModel.cs
new file mode 100644
index 0000000000..a6841a6d31
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Models/SharedChannelModel.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Bot.Schema.Teams;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace SharedChannelEvents.Models
+{
+ ///
+ /// Defines a state property to track information for shared channel events.
+ ///
+ public class SharedChannelData
+ {
+ public DateTime StartTime { get; set; }
+ }
+
+ ///
+ /// A minimal extension shape that mirrors the new PR fields on channelData.
+ ///
+ public class SharedChannelChannelData
+ {
+ [JsonProperty("eventType")]
+ public string EventType { get; set; }
+
+ [JsonProperty("channel")]
+ public ChannelInfo Channel { get; set; }
+
+ // Host team for the channel
+ [JsonProperty("team")]
+ public TeamInfoEx Team { get; set; }
+
+ // New arrays for shared/unshared teams
+ [JsonProperty("sharedWithTeams")]
+ public List SharedWithTeams { get; set; } = new List();
+
+ [JsonProperty("unsharedFromTeams")]
+ public List UnsharedFromTeams { get; set; } = new List();
+
+ // New: membership source for add/remove in shared channel
+ [JsonProperty("membershipSource")]
+ public MembershipSourceEx MembershipSource { get; set; }
+ }
+
+ ///
+ /// Extended team information for shared channels.
+ ///
+ public class TeamInfoEx
+ {
+ [JsonProperty("id")]
+ public string Id { get; set; }
+
+ [JsonProperty("aadGroupId")]
+ public string AadGroupId { get; set; }
+
+ [JsonProperty("tenantId")]
+ public string TenantId { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum MembershipSourceTypesEx
+ {
+ Team,
+ Channel
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum MembershipTypesEx
+ {
+ // Direct member of the channel
+ Direct,
+ // Member via the host/linked team (transitive)
+ Transitive
+ }
+
+ ///
+ /// Membership source information for shared channel members.
+ ///
+ public class MembershipSourceEx
+ {
+ [JsonProperty("sourceType")]
+ public string SourceType { get; set; }
+
+ // Unique identifier of the membership source (team or channel)
+ [JsonProperty("id")]
+ public string Id { get; set; }
+
+ [JsonProperty("membershipType")]
+ public string MembershipType { get; set; }
+
+ // AAD group id for the team associated with the membership
+ [JsonProperty("teamGroupId")]
+ public string TeamGroupId { get; set; }
+
+ [JsonProperty("tenantId")]
+ public string TenantId { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Program.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Program.cs
new file mode 100644
index 0000000000..31dd641450
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Program.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+//
+// Generated with Bot Builder V4 SDK Template for Visual Studio EchoBot v4.21.0
+
+namespace SharedChannelEvents
+{
+ using Microsoft.AspNetCore.Hosting;
+ using Microsoft.Extensions.Hosting;
+
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Properties/launchSettings.json b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Properties/launchSettings.json
new file mode 100644
index 0000000000..7ddb576d1d
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "Start Project": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "applicationUrl": "https://localhost:7130;http://localhost:5130",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "hotReloadProfile": "aspnetcore"
+ }
+ }
+}
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/SharedChannelEvents.csproj b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/SharedChannelEvents.csproj
new file mode 100644
index 0000000000..b7b8edf69a
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/SharedChannelEvents.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net6.0
+ latest
+ SharedChannelEvents
+ SharedChannelEvents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Startup.cs b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Startup.cs
new file mode 100644
index 0000000000..59b9ca5be9
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Startup.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace SharedChannelEvents
+{
+ using SharedChannelEvents.Bots;
+ using Microsoft.AspNetCore.Builder;
+ using Microsoft.AspNetCore.Hosting;
+ using Microsoft.Bot.Builder;
+ using Microsoft.Bot.Builder.Integration.AspNet.Core;
+ using Microsoft.Extensions.Configuration;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Hosting;
+
+ public class Startup
+ {
+ public Startup(IConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+
+ public IConfiguration Configuration { get; }
+
+ // This method gets called by the runtime. Use this method to add services to the container.
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddControllers().AddNewtonsoftJson();
+
+ // Create the Bot Framework Adapter with error handling enabled.
+ services.AddSingleton();
+
+ // Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
+ services.AddTransient();
+
+ // Creating the storage.
+ var storage = new MemoryStorage();
+
+ // Create the Conversation state passing in the storage layer.
+ var conversationState = new ConversationState(storage);
+ services.AddSingleton(conversationState);
+ }
+
+ // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseDefaultFiles()
+ .UseStaticFiles()
+ .UseWebSockets()
+ .UseRouting()
+ .UseAuthorization()
+ .UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.Development.json b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.Development.json
new file mode 100644
index 0000000000..4da6bd935d
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ }
+ }
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.json b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.json
new file mode 100644
index 0000000000..346c876719
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/appsettings.json
@@ -0,0 +1,6 @@
+{
+ "MicrosoftAppId": "",
+ "MicrosoftAppPassword": "",
+ "MicrosoftAppTenantId": "",
+ "MicrosoftAppType": "SingleTenant"
+}
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/SharedChannelEvents/wwwroot/default.htm b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/wwwroot/default.htm
new file mode 100644
index 0000000000..c8583c87c6
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/SharedChannelEvents/wwwroot/default.htm
@@ -0,0 +1,420 @@
+
+
+
+
+
+
+ SharedChannelEvents
+
+
+
+
+
+
+
+
+
SharedChannelEvents Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
Visit Azure
+ Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks
+ like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/bot-shared-channel-events/csharp/assets/sample.json b/samples/bot-shared-channel-events/csharp/assets/sample.json
new file mode 100644
index 0000000000..221213f8a7
--- /dev/null
+++ b/samples/bot-shared-channel-events/csharp/assets/sample.json
@@ -0,0 +1,68 @@
+[
+ {
+ "name": "officedev-microsoft-teams-samples-bot-shared-channel-events-csharp",
+ "source": "officeDev",
+ "title": "Microsoft Teams bot can receive transitive member add and remove events in shared channels.",
+ "shortDescription": "This sample demonstrates how a Microsoft Teams bot can receive and handle transitive member add and remove events in shared channels.",
+ "url": "https://github.com/OfficeDev/Microsoft-Teams-Samples/tree/main/samples/bot-shared-channel-events/csharp",
+ "longDescription": [
+ "This sample shows how to build a Microsoft Teams bot using the Bot Framework SDK that responds to transitive member changes in shared channels. When a member is added to or removed from a parent team that shares a channel, Teams automatically updates the membership of the shared channel. The bot receives these transitive member add and remove events and can process them to track membership changes, maintain rosters, or trigger custom workflows."
+ ],
+ "creationDateTime": "2025-10-09",
+ "updateDateTime": "2025-10-09",
+ "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"
+ }
+ ],
+ "thumbnails": [
+ {
+ "type": "image",
+ "order": 100,
+ "url": "https://raw.githubusercontent.com/OfficeDev/Microsoft-Teams-Samples/main/samples/bot-shared-channel-events/csharp/SharedChannelEvents/Images/SharedChannelEvents.gif",
+ "alt": "Solution UX showing bot can receive and handle transitive member add and remove events in shared channels"
+ }
+ ],
+ "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/community/videos/m365powerplatform"
+ }
+ ]
+ }
+]
\ No newline at end of file