From bb096b04913e7e7ba7ec535c06926d98ba764d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 22 Apr 2026 20:48:32 +0200 Subject: [PATCH 01/10] Migrate PSTeams surface to TeamsX cmdlets --- .github/workflows/test-powershell.yml | 83 +- Docs/PowerShell-Surface.md | 83 +- .../Adaptive Card Samples/ActivityUpdate.ps1 | 2 +- .../Adaptive Card Samples/ExpenseReport.ps1 | 2 +- .../Adaptive Card Samples/FlightItinerary.ps1 | 2 +- .../Adaptive Card Samples/SportingEvent.ps1 | 2 +- .../Adaptive Card Samples/StockUpdate.ps1 | 2 +- .../Adaptive Card Samples/WeatherCompact.ps1 | 2 +- .../Adaptive Card Samples/WeatherLarge.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Actions01.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-FromJson.ps1 | 4 +- .../Adaptive Card/AdaptiveCard-Images01.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Images02.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Native01.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Native02.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Native03.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Native04.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Native05.ps1 | 2 +- .../AdaptiveCard-Native06-MediaPlayer.ps1 | 2 +- .../AdaptiveCard-Native06_Escape.ps1 | 2 +- .../AdaptiveCard-Native07-RichTextBox.ps1 | 2 +- .../Adaptive Card/AdaptiveCard-Table01.ps1 | 2 +- .../AdaptiveCard-TypedActions.ps1 | 20 + Examples/Adaptive Card/AdaptiveCard.ps1 | 4 +- Examples/Example-Advanced1.ps1 | 2 +- Examples/Example-Advanced2.ps1 | 2 +- Examples/Example-Bug.ps1 | 4 +- Examples/Example-Short.ps1 | 4 +- Examples/Example-TweekLike2.ps1 | 2 +- Examples/Example-TweetLike1.ps1 | 2 +- Examples/Example-TweetLike3.ps1 | 4 +- Examples/Example2.ps1 | 2 +- Examples/Example3.ps1 | 2 +- Examples/Example4.ps1 | 4 +- Examples/Example5-ConvertToTeamsFacts.ps1 | 2 +- Examples/HeroCard/HeroCard-FromJson.ps1 | 4 +- Examples/HeroCard/HeroCard-Native.ps1 | 2 +- Examples/Import-PSTeams.ps1 | 13 + Examples/ListCard/CardList-FromJson.ps1 | 4 +- Examples/ListCard/CardList-Native.ps1 | 2 +- .../MessageCard/MessageCard-GraphChat.ps1 | 11 + Examples/MessageCard/MessageCard-Typed.ps1 | 17 + Examples/MessageCard/WrapperCard-Typed.ps1 | 30 + .../ThumbnailCard/ThumbnailCard-FromJson.ps1 | 4 +- .../ThumbnailCard/ThumbnailCard-Native.ps1 | 2 +- Module/Build/Build-Module.ps1 | 88 +- Module/PSTeams/Build/Build-Module.ps1 | 84 ++ {Images => Module/PSTeams/Images}/add.jpg | Bin {Images => Module/PSTeams/Images}/alert.jpg | Bin {Images => Module/PSTeams/Images}/cancel.jpg | Bin {Images => Module/PSTeams/Images}/check.jpg | Bin {Images => Module/PSTeams/Images}/disable.jpg | Bin .../PSTeams/Images}/download.jpg | Bin {Images => Module/PSTeams/Images}/info.jpg | Bin {Images => Module/PSTeams/Images}/minus.jpg | Bin .../PSTeams/Images}/question.jpg | Bin {Images => Module/PSTeams/Images}/reload.jpg | Bin Module/{ => PSTeams}/License | 0 .../PSTeams/PSTeams.Tests.ps1 | 9 +- Module/PSTeams/PSTeams.psd1 | 26 + Module/PSTeams/PSTeams.psm1 | 62 ++ Module/TeamsX.psd1 | 26 - Module/TeamsX.psm1 | 24 - Module/Tests/Binary.Compose.Tests.ps1 | 167 +++- Module/Tests/Import-Module.Tests.ps1 | 164 +++- .../Tests/Legacy.Adaptive.Compose.Tests.ps1 | 214 +++++ .../Legacy.MessageCard.Compose.Tests.ps1 | 114 +++ Module/Tests/WrapperCard.Compose.Tests.ps1 | 85 ++ PLAN.md | 8 +- PSTeams.psd1 | 27 - PSTeams.psm1 | 16 - Private/Add-TeamsBody.ps1 | 37 - Private/Convert-Color.ps1 | 60 -- Private/ConvertFrom-Color.ps1 | 31 - Private/Get-Image.ps1 | 21 - Private/Repair-Text.ps1 | 12 - Private/Script.RGBColors.ps1 | 752 ------------------ Public/ConvertTo-TeamsFact.ps1 | 45 -- Public/ConvertTo-TeamsSection.ps1 | 43 - Public/New-AdaptiveAction.ps1 | 31 - Public/New-AdaptiveActionSet.ps1 | 20 - Public/New-AdaptiveCard.ps1 | 311 -------- Public/New-AdaptiveColumn.ps1 | 80 -- Public/New-AdaptiveColumnSet.ps1 | 53 -- Public/New-AdaptiveContainer.ps1 | 85 -- Public/New-AdaptiveFact.ps1 | 14 - Public/New-AdaptiveFactSet.ps1 | 25 - Public/New-AdaptiveImage.ps1 | 162 ---- Public/New-AdaptiveImageSet.ps1 | 101 --- Public/New-AdaptiveLineBreak.ps1 | 7 - Public/New-AdaptiveMedia.ps1 | 87 -- Public/New-AdaptiveMediaSource.ps1 | 39 - Public/New-AdaptiveMention.ps1 | 70 -- Public/New-AdaptiveRichTextBlock.ps1 | 132 --- Public/New-AdaptiveTable.ps1 | 267 ------- Public/New-AdaptiveTextBlock.ps1 | 134 ---- Public/New-CardList.ps1 | 43 - Public/New-CardListButton.ps1 | 21 - Public/New-CardListItem.ps1 | 25 - Public/New-HeroCard.ps1 | 84 -- Public/New-TeamsActivityImage.ps1 | 75 -- Public/New-TeamsActivitySubtitle.ps1 | 11 - Public/New-TeamsActivityTItle.ps1 | 12 - Public/New-TeamsActivityText.ps1 | 11 - Public/New-TeamsBigImage.ps1 | 14 - Public/New-TeamsButton.ps1 | 79 -- Public/New-TeamsFact.ps1 | 15 - Public/New-TeamsImage.ps1 | 13 - Public/New-TeamsList.ps1 | 25 - Public/New-TeamsListItem.ps1 | 15 - Public/New-TeamsSection.ps1 | 134 ---- Public/New-ThumbnailCard.ps1 | 51 -- Public/Send-TeamsMessage.ps1 | 69 -- Public/Send-TeamsMessageBody.ps1 | 50 -- README.md | 95 ++- TeamsX.PowerShell/CmdletConvertToTeamsFact.cs | 109 +++ TeamsX.PowerShell/CmdletConvertToTeamsJson.cs | 36 +- .../CmdletConvertToTeamsSection.cs | 138 ++++ TeamsX.PowerShell/CmdletNewAdaptiveAction.cs | 87 ++ .../CmdletNewAdaptiveActionSet.cs | 106 +++ TeamsX.PowerShell/CmdletNewAdaptiveCard.cs | 175 ++++ TeamsX.PowerShell/CmdletNewAdaptiveColumn.cs | 121 +++ .../CmdletNewAdaptiveColumnSet.cs | 66 ++ .../CmdletNewAdaptiveContainer.cs | 145 ++++ TeamsX.PowerShell/CmdletNewAdaptiveFact.cs | 24 + TeamsX.PowerShell/CmdletNewAdaptiveFactSet.cs | 85 ++ TeamsX.PowerShell/CmdletNewAdaptiveImage.cs | 98 +++ .../CmdletNewAdaptiveImageSet.cs | 117 +++ .../CmdletNewAdaptiveLineBreak.cs | 17 + TeamsX.PowerShell/CmdletNewAdaptiveMedia.cs | 101 +++ .../CmdletNewAdaptiveMediaSource.cs | 24 + TeamsX.PowerShell/CmdletNewAdaptiveMention.cs | 34 + .../CmdletNewAdaptiveRichTextBlock.cs | 119 +++ TeamsX.PowerShell/CmdletNewAdaptiveTable.cs | 264 ++++++ .../CmdletNewAdaptiveTextBlock.cs | 93 +++ TeamsX.PowerShell/CmdletNewCardList.cs | 141 ++++ TeamsX.PowerShell/CmdletNewCardListButton.cs | 36 + TeamsX.PowerShell/CmdletNewCardListItem.cs | 45 ++ TeamsX.PowerShell/CmdletNewHeroCard.cs | 156 ++++ .../CmdletNewTeamsActivityImage.cs | 63 ++ .../CmdletNewTeamsActivitySubtitle.cs | 22 + .../CmdletNewTeamsActivityText.cs | 22 + .../CmdletNewTeamsActivityTitle.cs | 22 + .../CmdletNewTeamsAdaptiveCard.cs | 97 ++- .../CmdletNewTeamsAdaptiveColumn.cs | 79 +- .../CmdletNewTeamsAdaptiveColumnSet.cs | 35 +- .../CmdletNewTeamsAdaptiveContainer.cs | 114 ++- .../CmdletNewTeamsAdaptiveRichTextBlock.cs | 30 +- .../CmdletNewTeamsAdaptiveShowCardAction.cs | 114 +++ .../CmdletNewTeamsAdaptiveSubmitAction.cs | 21 + TeamsX.PowerShell/CmdletNewTeamsBigImage.cs | 30 + TeamsX.PowerShell/CmdletNewTeamsButton.cs | 36 + TeamsX.PowerShell/CmdletNewTeamsCardImage.cs | 30 + TeamsX.PowerShell/CmdletNewTeamsFact.cs | 25 + .../CmdletNewTeamsGraphTarget.cs | 77 ++ TeamsX.PowerShell/CmdletNewTeamsHeroCard.cs | 45 ++ TeamsX.PowerShell/CmdletNewTeamsImage.cs | 27 + TeamsX.PowerShell/CmdletNewTeamsList.cs | 93 +++ TeamsX.PowerShell/CmdletNewTeamsListCard.cs | 37 + TeamsX.PowerShell/CmdletNewTeamsListItem.cs | 29 + TeamsX.PowerShell/CmdletNewTeamsMessage.cs | 38 +- TeamsX.PowerShell/CmdletNewTeamsSection.cs | 250 ++++++ .../CmdletNewTeamsThumbnailCard.cs | 45 ++ TeamsX.PowerShell/CmdletNewThumbnailCard.cs | 102 +++ TeamsX.PowerShell/CmdletSendTeamsMessage.cs | 169 +++- .../CmdletSendTeamsMessageBody.cs | 64 ++ .../TeamsAdaptiveActionSupport.cs | 43 + .../TeamsAdaptiveCardDictionarySupport.cs | 297 +++++++ .../TeamsPowerShellDeliverySupport.cs | 63 ++ .../TeamsPowerShellGraphTokenSupport.cs | 35 + .../TeamsPowerShellImageSupport.cs | 38 + TeamsX.PowerShell/TeamsX.PowerShell.csproj | 4 + TeamsX.Tests/GraphMessageRendererTests.cs | 85 ++ TeamsX.Tests/GraphTeamsMessageSenderTests.cs | 83 ++ TeamsX.Tests/TeamsAdaptiveCardTests.cs | 21 + TeamsX.Tests/TeamsClientTests.cs | 35 + TeamsX.Tests/TeamsMessageTargetTests.cs | 42 + TeamsX.Tests/WebhookMessageRendererTests.cs | 142 ++++ TeamsX/GraphMessageRenderer.cs | 214 +++++ TeamsX/GraphTeamsMessageSender.cs | 99 +++ TeamsX/TeamsAdaptiveAction.cs | 1 + TeamsX/TeamsAdaptiveCard.cs | 9 + TeamsX/TeamsAdaptiveColumn.cs | 9 + TeamsX/TeamsAdaptiveColumnSet.cs | 7 + TeamsX/TeamsAdaptiveContainer.cs | 12 + TeamsX/TeamsAdaptiveFactSet.cs | 3 + TeamsX/TeamsAdaptiveImage.cs | 10 + TeamsX/TeamsAdaptiveImageSet.cs | 6 + TeamsX/TeamsAdaptiveRichTextBlock.cs | 6 + TeamsX/TeamsAdaptiveShowCardAction.cs | 7 + TeamsX/TeamsAdaptiveSubmitAction.cs | 5 + TeamsX/TeamsAdaptiveTextBlock.cs | 14 +- TeamsX/TeamsCardButton.cs | 11 + TeamsX/TeamsCardButtonActionType.cs | 10 + TeamsX/TeamsCardImage.cs | 9 + TeamsX/TeamsClient.cs | 54 +- TeamsX/TeamsColorUtility.cs | 41 + TeamsX/TeamsHeroCard.cs | 12 + TeamsX/TeamsImageDataUtility.cs | 15 + TeamsX/TeamsLegacyAdaptiveNormalizer.cs | 305 +++++++ TeamsX/TeamsListCard.cs | 10 + TeamsX/TeamsListCardItem.cs | 14 + TeamsX/TeamsListCardItemKind.cs | 11 + TeamsX/TeamsMessageButton.cs | 10 + TeamsX/TeamsMessageButtonType.cs | 12 + TeamsX/TeamsMessageFact.cs | 9 + TeamsX/TeamsMessageImage.cs | 9 + TeamsX/TeamsMessageListItem.cs | 12 + TeamsX/TeamsMessageRequest.cs | 4 + TeamsX/TeamsMessageSection.cs | 19 + TeamsX/TeamsMessageSectionDirective.cs | 9 + TeamsX/TeamsMessageSectionDirectiveType.cs | 11 + TeamsX/TeamsMessageTarget.cs | 101 +++ TeamsX/TeamsThumbnailCard.cs | 12 + TeamsX/TeamsWrapperCardRenderer.cs | 135 ++++ TeamsX/WebhookMessageRenderer.cs | 237 +++++- Tests/ConvertTo-TeamsFact.Tests.ps1 | 37 - Tests/Send-AdaptiveCard.Tests.ps1 | 53 -- Tests/Send-CardList.Tests.ps1 | 17 - Tests/Send-HeroCard.Tests.ps1 | 15 - Tests/Send-TeamsMessage.Tests.ps1 | 51 -- Tests/Send-TeamsNew.Tests.ps1 | 20 - Tests/Send-Thumbnail.Tests.ps1 | 15 - 223 files changed, 7658 insertions(+), 3891 deletions(-) create mode 100644 Examples/Adaptive Card/AdaptiveCard-TypedActions.ps1 create mode 100644 Examples/Import-PSTeams.ps1 create mode 100644 Examples/MessageCard/MessageCard-GraphChat.ps1 create mode 100644 Examples/MessageCard/MessageCard-Typed.ps1 create mode 100644 Examples/MessageCard/WrapperCard-Typed.ps1 create mode 100644 Module/PSTeams/Build/Build-Module.ps1 rename {Images => Module/PSTeams/Images}/add.jpg (100%) rename {Images => Module/PSTeams/Images}/alert.jpg (100%) rename {Images => Module/PSTeams/Images}/cancel.jpg (100%) rename {Images => Module/PSTeams/Images}/check.jpg (100%) rename {Images => Module/PSTeams/Images}/disable.jpg (100%) rename {Images => Module/PSTeams/Images}/download.jpg (100%) rename {Images => Module/PSTeams/Images}/info.jpg (100%) rename {Images => Module/PSTeams/Images}/minus.jpg (100%) rename {Images => Module/PSTeams/Images}/question.jpg (100%) rename {Images => Module/PSTeams/Images}/reload.jpg (100%) rename Module/{ => PSTeams}/License (100%) rename PSTeams.Tests.ps1 => Module/PSTeams/PSTeams.Tests.ps1 (88%) create mode 100644 Module/PSTeams/PSTeams.psd1 create mode 100644 Module/PSTeams/PSTeams.psm1 delete mode 100644 Module/TeamsX.psd1 delete mode 100644 Module/TeamsX.psm1 create mode 100644 Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 create mode 100644 Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 create mode 100644 Module/Tests/WrapperCard.Compose.Tests.ps1 delete mode 100644 PSTeams.psd1 delete mode 100644 PSTeams.psm1 delete mode 100644 Private/Add-TeamsBody.ps1 delete mode 100644 Private/Convert-Color.ps1 delete mode 100644 Private/ConvertFrom-Color.ps1 delete mode 100644 Private/Get-Image.ps1 delete mode 100644 Private/Repair-Text.ps1 delete mode 100644 Private/Script.RGBColors.ps1 delete mode 100644 Public/ConvertTo-TeamsFact.ps1 delete mode 100644 Public/ConvertTo-TeamsSection.ps1 delete mode 100644 Public/New-AdaptiveAction.ps1 delete mode 100644 Public/New-AdaptiveActionSet.ps1 delete mode 100644 Public/New-AdaptiveCard.ps1 delete mode 100644 Public/New-AdaptiveColumn.ps1 delete mode 100644 Public/New-AdaptiveColumnSet.ps1 delete mode 100644 Public/New-AdaptiveContainer.ps1 delete mode 100644 Public/New-AdaptiveFact.ps1 delete mode 100644 Public/New-AdaptiveFactSet.ps1 delete mode 100644 Public/New-AdaptiveImage.ps1 delete mode 100644 Public/New-AdaptiveImageSet.ps1 delete mode 100644 Public/New-AdaptiveLineBreak.ps1 delete mode 100644 Public/New-AdaptiveMedia.ps1 delete mode 100644 Public/New-AdaptiveMediaSource.ps1 delete mode 100644 Public/New-AdaptiveMention.ps1 delete mode 100644 Public/New-AdaptiveRichTextBlock.ps1 delete mode 100644 Public/New-AdaptiveTable.ps1 delete mode 100644 Public/New-AdaptiveTextBlock.ps1 delete mode 100644 Public/New-CardList.ps1 delete mode 100644 Public/New-CardListButton.ps1 delete mode 100644 Public/New-CardListItem.ps1 delete mode 100644 Public/New-HeroCard.ps1 delete mode 100644 Public/New-TeamsActivityImage.ps1 delete mode 100644 Public/New-TeamsActivitySubtitle.ps1 delete mode 100644 Public/New-TeamsActivityTItle.ps1 delete mode 100644 Public/New-TeamsActivityText.ps1 delete mode 100644 Public/New-TeamsBigImage.ps1 delete mode 100644 Public/New-TeamsButton.ps1 delete mode 100644 Public/New-TeamsFact.ps1 delete mode 100644 Public/New-TeamsImage.ps1 delete mode 100644 Public/New-TeamsList.ps1 delete mode 100644 Public/New-TeamsListItem.ps1 delete mode 100644 Public/New-TeamsSection.ps1 delete mode 100644 Public/New-ThumbnailCard.ps1 delete mode 100644 Public/Send-TeamsMessage.ps1 delete mode 100644 Public/Send-TeamsMessageBody.ps1 create mode 100644 TeamsX.PowerShell/CmdletConvertToTeamsFact.cs create mode 100644 TeamsX.PowerShell/CmdletConvertToTeamsSection.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveAction.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveActionSet.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveCard.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveColumn.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveColumnSet.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveContainer.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveFact.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveFactSet.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveImage.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveImageSet.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveLineBreak.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveMedia.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveMediaSource.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveMention.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveRichTextBlock.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveTable.cs create mode 100644 TeamsX.PowerShell/CmdletNewAdaptiveTextBlock.cs create mode 100644 TeamsX.PowerShell/CmdletNewCardList.cs create mode 100644 TeamsX.PowerShell/CmdletNewCardListButton.cs create mode 100644 TeamsX.PowerShell/CmdletNewCardListItem.cs create mode 100644 TeamsX.PowerShell/CmdletNewHeroCard.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsActivityImage.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsActivitySubtitle.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsActivityText.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsActivityTitle.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsAdaptiveShowCardAction.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsAdaptiveSubmitAction.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsBigImage.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsButton.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsCardImage.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsFact.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsGraphTarget.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsHeroCard.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsImage.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsList.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsListCard.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsListItem.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsSection.cs create mode 100644 TeamsX.PowerShell/CmdletNewTeamsThumbnailCard.cs create mode 100644 TeamsX.PowerShell/CmdletNewThumbnailCard.cs create mode 100644 TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs create mode 100644 TeamsX.PowerShell/TeamsAdaptiveActionSupport.cs create mode 100644 TeamsX.PowerShell/TeamsAdaptiveCardDictionarySupport.cs create mode 100644 TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs create mode 100644 TeamsX.PowerShell/TeamsPowerShellGraphTokenSupport.cs create mode 100644 TeamsX.PowerShell/TeamsPowerShellImageSupport.cs create mode 100644 TeamsX.Tests/GraphMessageRendererTests.cs create mode 100644 TeamsX.Tests/GraphTeamsMessageSenderTests.cs create mode 100644 TeamsX/GraphMessageRenderer.cs create mode 100644 TeamsX/GraphTeamsMessageSender.cs create mode 100644 TeamsX/TeamsAdaptiveShowCardAction.cs create mode 100644 TeamsX/TeamsAdaptiveSubmitAction.cs create mode 100644 TeamsX/TeamsCardButton.cs create mode 100644 TeamsX/TeamsCardButtonActionType.cs create mode 100644 TeamsX/TeamsCardImage.cs create mode 100644 TeamsX/TeamsColorUtility.cs create mode 100644 TeamsX/TeamsHeroCard.cs create mode 100644 TeamsX/TeamsImageDataUtility.cs create mode 100644 TeamsX/TeamsLegacyAdaptiveNormalizer.cs create mode 100644 TeamsX/TeamsListCard.cs create mode 100644 TeamsX/TeamsListCardItem.cs create mode 100644 TeamsX/TeamsListCardItemKind.cs create mode 100644 TeamsX/TeamsMessageButton.cs create mode 100644 TeamsX/TeamsMessageButtonType.cs create mode 100644 TeamsX/TeamsMessageFact.cs create mode 100644 TeamsX/TeamsMessageImage.cs create mode 100644 TeamsX/TeamsMessageListItem.cs create mode 100644 TeamsX/TeamsMessageSection.cs create mode 100644 TeamsX/TeamsMessageSectionDirective.cs create mode 100644 TeamsX/TeamsMessageSectionDirectiveType.cs create mode 100644 TeamsX/TeamsThumbnailCard.cs create mode 100644 TeamsX/TeamsWrapperCardRenderer.cs delete mode 100644 Tests/ConvertTo-TeamsFact.Tests.ps1 delete mode 100644 Tests/Send-AdaptiveCard.Tests.ps1 delete mode 100644 Tests/Send-CardList.Tests.ps1 delete mode 100644 Tests/Send-HeroCard.Tests.ps1 delete mode 100644 Tests/Send-TeamsMessage.Tests.ps1 delete mode 100644 Tests/Send-TeamsNew.Tests.ps1 delete mode 100644 Tests/Send-Thumbnail.Tests.ps1 diff --git a/.github/workflows/test-powershell.yml b/.github/workflows/test-powershell.yml index f7f4e03..1c12aec 100644 --- a/.github/workflows/test-powershell.yml +++ b/.github/workflows/test-powershell.yml @@ -18,10 +18,38 @@ permissions: contents: read env: + DOTNET_VERSION: '10.x' BUILD_CONFIGURATION: 'Debug' jobs: + refresh-psd1: + name: 'Refresh PSD1' + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PowerShell modules + shell: pwsh + run: | + Install-Module PSPublishModule -Force -Scope CurrentUser -AllowClobber + + - name: Refresh module manifest + shell: pwsh + env: + RefreshPSD1Only: 'true' + run: | + ./Module/Build/Build-Module.ps1 + + - name: Upload refreshed manifest + uses: actions/upload-artifact@v4 + with: + name: psd1 + path: Module/PSTeams/PSTeams.psd1 + test-windows-ps5: + needs: refresh-psd1 name: 'Windows PowerShell 5.1' runs-on: windows-latest timeout-minutes: 10 @@ -29,18 +57,23 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: psd1 + path: Module/PSTeams + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.x - 10.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Install PowerShell modules shell: powershell run: | Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - name: Build .NET solution run: | @@ -49,9 +82,10 @@ jobs: - name: Run PowerShell tests shell: powershell - run: Invoke-Pester -Path ./Module/Tests -Output Detailed + run: ./Module/PSTeams/PSTeams.Tests.ps1 test-windows-ps7: + needs: refresh-psd1 name: 'Windows PowerShell 7' runs-on: windows-latest timeout-minutes: 10 @@ -59,18 +93,23 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: psd1 + path: Module/PSTeams + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.x - 10.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Install PowerShell modules shell: pwsh run: | Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - name: Build .NET solution run: | @@ -79,9 +118,10 @@ jobs: - name: Run PowerShell tests shell: pwsh - run: Invoke-Pester -Path ./Module/Tests -Output Detailed + run: ./Module/PSTeams/PSTeams.Tests.ps1 test-ubuntu: + needs: refresh-psd1 name: 'Ubuntu PowerShell 7' runs-on: ubuntu-latest timeout-minutes: 10 @@ -89,12 +129,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: psd1 + path: Module/PSTeams + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.x - 10.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Install PowerShell run: | @@ -109,6 +153,7 @@ jobs: run: | Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - name: Build .NET solution run: | @@ -117,9 +162,10 @@ jobs: - name: Run PowerShell tests shell: pwsh - run: Invoke-Pester -Path ./Module/Tests -Output Detailed + run: ./Module/PSTeams/PSTeams.Tests.ps1 test-macos: + needs: refresh-psd1 name: 'macOS PowerShell 7' runs-on: macos-latest timeout-minutes: 10 @@ -127,12 +173,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: psd1 + path: Module/PSTeams + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.x - 10.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Install PowerShell shell: bash @@ -151,6 +201,7 @@ jobs: run: | Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - name: Build .NET solution run: | @@ -159,4 +210,4 @@ jobs: - name: Run PowerShell tests shell: pwsh - run: Invoke-Pester -Path ./Module/Tests -Output Detailed + run: ./Module/PSTeams/PSTeams.Tests.ps1 diff --git a/Docs/PowerShell-Surface.md b/Docs/PowerShell-Surface.md index 834c7b9..86dd1c9 100644 --- a/Docs/PowerShell-Surface.md +++ b/Docs/PowerShell-Surface.md @@ -1,13 +1,42 @@ # PowerShell Surface -`main` exposes a cmdlet-only PowerShell API. +`main` exposes one `PSTeams` module whose public surface is now binary-backed through `TeamsX.PowerShell`, with the shipping shell in `Module\PSTeams`. + +## Current Module Shape + +- `TeamsX` is the reusable .NET library +- `TeamsX.PowerShell` is the thin binary cmdlet layer +- `Module\PSTeams` is the shipping module shell and alias bridge +- Legacy public names are preserved as cmdlets and aliases, not script functions +- The runtime module import is now binary-only: the shell loads `TeamsX.PowerShell.dll`, sets aliases, and does not dot-source public/private script functions +- The old module-local helper scripts and stale in-module legacy tests have been removed; the active validation suite lives in `Module\Tests` ## Current Cmdlets +- `New-AdaptiveAction` +- `New-AdaptiveActionSet` +- `New-AdaptiveCard` +- `New-AdaptiveColumn` +- `New-AdaptiveColumnSet` +- `New-AdaptiveContainer` +- `New-AdaptiveFact` +- `New-AdaptiveFactSet` +- `New-AdaptiveImage` +- `New-AdaptiveImageSet` +- `New-AdaptiveLineBreak` +- `New-AdaptiveMedia` +- `New-AdaptiveMediaSource` +- `New-AdaptiveMention` +- `New-AdaptiveRichTextBlock` +- `New-AdaptiveTable` +- `New-AdaptiveTextBlock` - `ConvertTo-TeamsJson` +- `New-TeamsGraphTarget` +- `New-TeamsHeroCard` +- `New-TeamsThumbnailCard` +- `New-TeamsListCard` - `New-TeamsMessage` - `New-TeamsWebhookTarget` -- `Send-TeamsMessage` - `New-TeamsAdaptiveCard` - `New-TeamsAdaptiveTextBlock` - `New-TeamsAdaptiveRichTextBlock` @@ -22,16 +51,28 @@ - `New-TeamsAdaptiveColumn` - `New-TeamsAdaptiveColumnSet` - `New-TeamsAdaptiveOpenUrlAction` +- `New-TeamsAdaptiveShowCardAction` +- `New-TeamsAdaptiveSubmitAction` - `New-TeamsAdaptiveToggleVisibilityAction` - `New-TeamsAdaptiveActionSet` - `New-TeamsAdaptiveTextRun` +## Migration Status + +- `FunctionsToExport` in `Module\PSTeams\PSTeams.psd1` is now empty. +- The whole `New-Adaptive*` surface is binary-backed on `main`. +- `Module\PSTeams\PSTeams.psm1` now prefers the highest compatible local PowerShell Core build, including `net10.0` when the current host runtime can load it. +- Remaining work is now quality and parity polish: warnings cleanup, docs/examples refresh, and feature expansion on the typed cmdlet surface. +- `TeamsX` now includes a Graph sender starter for channel and chat posts, exposed through `New-TeamsGraphTarget`. + ## Design Rules - New public PowerShell features should be implemented as C# cmdlets. - `TeamsX.PowerShell` should stay thin over `TeamsX`. - If a feature needs more composition support, add typed .NET models first, then expose cmdlets. -- Do not add new wrapper functions for old `PSTeams` command names on `main`. +- Keep the existing `PSTeams` public names available, but prefer implementing them as cmdlets or aliases. +- Delete PowerShell implementations only after the matching C# cmdlet path is in place and tested. +- Keep new delivery backends dependency-light; prefer direct HTTP clients over large SDK dependencies unless the SDK adds clear value. - Use `Build\Build-Project.ps1` for project/library release flow. - Use `Module\Build\Build-Module.ps1` for PowerShell module packaging flow. @@ -51,3 +92,39 @@ $card = New-TeamsAdaptiveCard -Body @( $message = New-TeamsMessage -Summary 'Build notification' -AdaptiveCard $card $json = $message | ConvertTo-TeamsJson ``` + +Typed wrapper cards can also be composed as objects and rendered through `ConvertTo-TeamsJson`: + +```powershell +$target = New-TeamsWebhookTarget -Uri 'https://example.test/webhook' +$heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' -AlternateText 'Monorail' +) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' +) + +Send-TeamsMessage -HeroCard $heroCard -Target $target + +$json = $heroCard | ConvertTo-TeamsJson +$wrapped = $json | Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Wrap -Supress:$false -WhatIf +``` + +## Graph Starter + +`main` now includes a starter Graph target cmdlet for chat and channel posts: + +```powershell +$message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42 stopped.' +$target = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessTokenVariableName 'TEAMSX_GRAPH_TOKEN' + +Send-TeamsMessage -Message $message -Target $target +``` + +Current scope: + +- plain typed messages are rendered as Graph HTML message bodies +- adaptive cards are sent as Graph attachments +- adaptive cards should currently stick to `Action.OpenUrl` +- typed wrapper-card direct sending currently targets incoming and workflow webhooks only +- Graph targets can use a plain token, a secure string, or an environment-variable-backed token provider +- normal Graph chat/channel posting should use delegated tokens; application permissions are documented as migration-only for these endpoints diff --git a/Examples/Adaptive Card Samples/ActivityUpdate.ps1 b/Examples/Adaptive Card Samples/ActivityUpdate.ps1 index adea0d5..a9e70f3 100644 --- a/Examples/Adaptive Card Samples/ActivityUpdate.ps1 +++ b/Examples/Adaptive Card Samples/ActivityUpdate.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Based on: https://adaptivecards.io/samples/ActivityUpdate.html diff --git a/Examples/Adaptive Card Samples/ExpenseReport.ps1 b/Examples/Adaptive Card Samples/ExpenseReport.ps1 index 848309a..bb1b020 100644 --- a/Examples/Adaptive Card Samples/ExpenseReport.ps1 +++ b/Examples/Adaptive Card Samples/ExpenseReport.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Based on: https://adaptivecards.io/samples/ExpenseReport.html diff --git a/Examples/Adaptive Card Samples/FlightItinerary.ps1 b/Examples/Adaptive Card Samples/FlightItinerary.ps1 index e2a04d4..eac1667 100644 --- a/Examples/Adaptive Card Samples/FlightItinerary.ps1 +++ b/Examples/Adaptive Card Samples/FlightItinerary.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Based on: https://adaptivecards.io/samples/FlightItinerary.html diff --git a/Examples/Adaptive Card Samples/SportingEvent.ps1 b/Examples/Adaptive Card Samples/SportingEvent.ps1 index 384d398..195b10b 100644 --- a/Examples/Adaptive Card Samples/SportingEvent.ps1 +++ b/Examples/Adaptive Card Samples/SportingEvent.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveContainer { diff --git a/Examples/Adaptive Card Samples/StockUpdate.ps1 b/Examples/Adaptive Card Samples/StockUpdate.ps1 index d357e41..59322e8 100644 --- a/Examples/Adaptive Card Samples/StockUpdate.ps1 +++ b/Examples/Adaptive Card Samples/StockUpdate.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveContainer { diff --git a/Examples/Adaptive Card Samples/WeatherCompact.ps1 b/Examples/Adaptive Card Samples/WeatherCompact.ps1 index 94a8727..8281da5 100644 --- a/Examples/Adaptive Card Samples/WeatherCompact.ps1 +++ b/Examples/Adaptive Card Samples/WeatherCompact.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Based on: https://adaptivecards.io/samples/WeatherCompact.html diff --git a/Examples/Adaptive Card Samples/WeatherLarge.ps1 b/Examples/Adaptive Card Samples/WeatherLarge.ps1 index ef32006..465c77f 100644 --- a/Examples/Adaptive Card Samples/WeatherLarge.ps1 +++ b/Examples/Adaptive Card Samples/WeatherLarge.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Based on: https://adaptivecards.io/samples/WeatherLarge.html diff --git a/Examples/Adaptive Card/AdaptiveCard-Actions01.ps1 b/Examples/Adaptive Card/AdaptiveCard-Actions01.ps1 index 656fcbf..7341279 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Actions01.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Actions01.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-FromJson.ps1 b/Examples/Adaptive Card/AdaptiveCard-FromJson.ps1 index e2e0286..d3367e1 100644 --- a/Examples/Adaptive Card/AdaptiveCard-FromJson.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-FromJson.ps1 @@ -1,4 +1,6 @@ -$Wrapper = @" +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$Wrapper = @" { "contentType": "application/vnd.microsoft.card.adaptive", "content": { diff --git a/Examples/Adaptive Card/AdaptiveCard-Images01.ps1 b/Examples/Adaptive Card/AdaptiveCard-Images01.ps1 index b459216..092efde 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Images01.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Images01.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-Images02.ps1 b/Examples/Adaptive Card/AdaptiveCard-Images02.ps1 index 064af50..ccec0b6 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Images02.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Images02.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-Native01.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native01.ps1 index 22e6b1e..b51a181 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native01.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native01.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard { New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation.' -Separator -Wrap diff --git a/Examples/Adaptive Card/AdaptiveCard-Native02.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native02.ps1 index 973ab63..644985b 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native02.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native02.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID { New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Test' diff --git a/Examples/Adaptive Card/AdaptiveCard-Native03.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native03.ps1 index 6afc4c5..1afe2e1 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native03.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native03.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID { New-AdaptiveContainer { diff --git a/Examples/Adaptive Card/AdaptiveCard-Native04.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native04.ps1 index 4b60863..2e40462 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native04.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native04.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-Native05.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native05.ps1 index bf44a45..b50fb66 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native05.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native05.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-Native06-MediaPlayer.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native06-MediaPlayer.ps1 index bc45646..ae3ea6d 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native06-MediaPlayer.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native06-MediaPlayer.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center diff --git a/Examples/Adaptive Card/AdaptiveCard-Native06_Escape.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native06_Escape.ps1 index ecf3510..9bdfc55 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native06_Escape.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native06_Escape.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveColumnSet { diff --git a/Examples/Adaptive Card/AdaptiveCard-Native07-RichTextBox.ps1 b/Examples/Adaptive Card/AdaptiveCard-Native07-RichTextBox.ps1 index ab89157..13b1bad 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Native07-RichTextBox.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Native07-RichTextBox.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { New-AdaptiveContainer { diff --git a/Examples/Adaptive Card/AdaptiveCard-Table01.ps1 b/Examples/Adaptive Card/AdaptiveCard-Table01.ps1 index 96af318..a84d46c 100644 --- a/Examples/Adaptive Card/AdaptiveCard-Table01.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard-Table01.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Lets prepare dummmy object array with few elements $Objects = @( diff --git a/Examples/Adaptive Card/AdaptiveCard-TypedActions.ps1 b/Examples/Adaptive Card/AdaptiveCard-TypedActions.ps1 new file mode 100644 index 0000000..d9784d7 --- /dev/null +++ b/Examples/Adaptive Card/AdaptiveCard-TypedActions.ps1 @@ -0,0 +1,20 @@ +. $PSScriptRoot\..\Import-PSTeams.ps1 + +$card = New-TeamsAdaptiveCard -FallbackText 'Build failed' -Body @( + New-TeamsAdaptiveTextBlock -Text 'Build failed' -Weight Bolder -Color Attention + New-TeamsAdaptiveFactSet -Facts @( + New-TeamsAdaptiveFact -Title 'Run' -Value '42' + New-TeamsAdaptiveFact -Title 'Status' -Value 'Failed' + ) +) -Actions @( + New-TeamsAdaptiveOpenUrlAction -Title 'Open build' -Url 'https://example.test/build/42' + New-TeamsAdaptiveSubmitAction -Title 'Acknowledge' + New-TeamsAdaptiveShowCardAction -Title 'Details' -Body @( + New-TeamsAdaptiveTextBlock -Text 'Nested details' + ) -Actions @( + New-TeamsAdaptiveSubmitAction -Title 'Confirm' + ) +) + +$message = New-TeamsMessage -Summary 'Build notification' -AdaptiveCard $card +$message | ConvertTo-TeamsJson diff --git a/Examples/Adaptive Card/AdaptiveCard.ps1 b/Examples/Adaptive Card/AdaptiveCard.ps1 index 0eaac8f..e189fda 100644 --- a/Examples/Adaptive Card/AdaptiveCard.ps1 +++ b/Examples/Adaptive Card/AdaptiveCard.ps1 @@ -1,4 +1,6 @@ -New-AdaptiveCard -Uri $Env:TEAMSPESTERID { +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +New-AdaptiveCard -Uri $Env:TEAMSPESTERID { New-AdaptiveContainer { New-AdaptiveTextBlock -Text 'Publish Adaptive Card schema' -Weight Bolder -Size Medium New-AdaptiveColumnSet { diff --git a/Examples/Example-Advanced1.ps1 b/Examples/Example-Advanced1.ps1 index e61fcfa..d579482 100644 --- a/Examples/Example-Advanced1.ps1 +++ b/Examples/Example-Advanced1.ps1 @@ -1,4 +1,4 @@ -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') $TeamsID = 'https://outlook.office.com/webhook/a5c7c' diff --git a/Examples/Example-Advanced2.ps1 b/Examples/Example-Advanced2.ps1 index 96a6855..7efb2aa 100644 --- a/Examples/Example-Advanced2.ps1 +++ b/Examples/Example-Advanced2.ps1 @@ -1,4 +1,4 @@ -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') # This is fake TeamsID - you need to use yours $TeamsID = 'https://outlook.office.ad05-32e40' diff --git a/Examples/Example-Bug.ps1 b/Examples/Example-Bug.ps1 index 1a4989f..047f444 100644 --- a/Examples/Example-Bug.ps1 +++ b/Examples/Example-Bug.ps1 @@ -1,4 +1,6 @@ -Send-TeamsMessage -Verbose -Color DimGray { +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') + +Send-TeamsMessage -Verbose -Color DimGray { New-TeamsSection -Title 'This is 2nd section within 1 message' { New-TeamsList -Name 'Testing List' { New-TeamsListItem -Text 'First ordered list item' -Level 0 diff --git a/Examples/Example-Short.ps1 b/Examples/Example-Short.ps1 index 2ee370c..3b50789 100644 --- a/Examples/Example-Short.ps1 +++ b/Examples/Example-Short.ps1 @@ -1 +1,3 @@ -Send-TeamsMessage -URI $TeamsID -MessageText "This text will show up" \ No newline at end of file +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') + +Send-TeamsMessage -URI $TeamsID -MessageText "This text will show up" \ No newline at end of file diff --git a/Examples/Example-TweekLike2.ps1 b/Examples/Example-TweekLike2.ps1 index d55585c..8d3a023 100644 --- a/Examples/Example-TweekLike2.ps1 +++ b/Examples/Example-TweekLike2.ps1 @@ -1,4 +1,4 @@ -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') $TeamsID = 'https://outlook.office.com/webhook/a5c7c' diff --git a/Examples/Example-TweetLike1.ps1 b/Examples/Example-TweetLike1.ps1 index 5b5667d..ea89bd1 100644 --- a/Examples/Example-TweetLike1.ps1 +++ b/Examples/Example-TweetLike1.ps1 @@ -1,4 +1,4 @@ -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') $TeamsID = 'https://outlook.office.com/webhook/a5c7c' diff --git a/Examples/Example-TweetLike3.ps1 b/Examples/Example-TweetLike3.ps1 index d752157..19d488c 100644 --- a/Examples/Example-TweetLike3.ps1 +++ b/Examples/Example-TweetLike3.ps1 @@ -1,4 +1,6 @@ -Send-TeamsMessage -Verbose { +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') + +Send-TeamsMessage -Verbose { New-TeamsSection { ActivityTitle -Title "**Elon Musk**" ActivitySubtitle -Subtitle "@elonmusk - 9/12/2016 at 5:33pm" diff --git a/Examples/Example2.ps1 b/Examples/Example2.ps1 index b2ed441..3e3ae7b 100644 --- a/Examples/Example2.ps1 +++ b/Examples/Example2.ps1 @@ -2,7 +2,7 @@ param ( $TeamsID = $Env:TEAMSPESTERID ) -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') $Button1 = New-TeamsButton -Name 'Visit English Evotec Website' -Link "https://evotec.xyz" diff --git a/Examples/Example3.ps1 b/Examples/Example3.ps1 index f351c35..4d1dca5 100644 --- a/Examples/Example3.ps1 +++ b/Examples/Example3.ps1 @@ -2,7 +2,7 @@ param ( $TeamsID = $Env:TEAMSPESTERID ) -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') Send-TeamsMessage ` -URI $TeamsID ` diff --git a/Examples/Example4.ps1 b/Examples/Example4.ps1 index c03dce0..1c96c1a 100644 --- a/Examples/Example4.ps1 +++ b/Examples/Example4.ps1 @@ -1,8 +1,8 @@ -param ( +param ( $TeamsID = $Env:TEAMSPESTERID ) -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') # keep in mind for Emoji you may need UTF-8 with BOM diff --git a/Examples/Example5-ConvertToTeamsFacts.ps1 b/Examples/Example5-ConvertToTeamsFacts.ps1 index 3fb1141..0920230 100644 --- a/Examples/Example5-ConvertToTeamsFacts.ps1 +++ b/Examples/Example5-ConvertToTeamsFacts.ps1 @@ -1,4 +1,4 @@ -Import-Module $PSScriptRoot\..\PSTeams.psd1 -Force #-Verbose +. (Join-Path $PSScriptRoot 'Import-PSTeams.ps1') Get-ChildItem | Select-Object -First 2 | ConvertTo-TeamsFact diff --git a/Examples/HeroCard/HeroCard-FromJson.ps1 b/Examples/HeroCard/HeroCard-FromJson.ps1 index 28f1635..5272536 100644 --- a/Examples/HeroCard/HeroCard-FromJson.ps1 +++ b/Examples/HeroCard/HeroCard-FromJson.ps1 @@ -1,4 +1,6 @@ -$Wrapper = @" +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$Wrapper = @" { "contentType": "application/vnd.microsoft.card.hero", "content": { diff --git a/Examples/HeroCard/HeroCard-Native.ps1 b/Examples/HeroCard/HeroCard-Native.ps1 index 3076517..ad34e63 100644 --- a/Examples/HeroCard/HeroCard-Native.ps1 +++ b/Examples/HeroCard/HeroCard-Native.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-HeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text "The Seattle Center Monorail is an elevated train line between Seattle Center (near the Space Needle) and downtown Seattle. It was built for the 1962 World's Fair. Its original two trains, completed in 1961, are still in service." { New-HeroImage -Url 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Seattle_monorail01_2008-02-25.jpg/1024px-Seattle_monorail01_2008-02-25.jpg' diff --git a/Examples/Import-PSTeams.ps1 b/Examples/Import-PSTeams.ps1 new file mode 100644 index 0000000..468ff31 --- /dev/null +++ b/Examples/Import-PSTeams.ps1 @@ -0,0 +1,13 @@ +$currentDirectory = [System.IO.DirectoryInfo]$PSScriptRoot + +while ($null -ne $currentDirectory) { + $modulePath = Join-Path $currentDirectory.FullName 'Module\PSTeams\PSTeams.psd1' + if (Test-Path -LiteralPath $modulePath) { + Import-Module -Name $modulePath -Force + return + } + + $currentDirectory = $currentDirectory.Parent +} + +throw "Unable to locate Module\\PSTeams\\PSTeams.psd1 from '$PSScriptRoot'." diff --git a/Examples/ListCard/CardList-FromJson.ps1 b/Examples/ListCard/CardList-FromJson.ps1 index fc3b6e0..3edcf7f 100644 --- a/Examples/ListCard/CardList-FromJson.ps1 +++ b/Examples/ListCard/CardList-FromJson.ps1 @@ -1,4 +1,6 @@ -$Wrapper = @" +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$Wrapper = @" { "contentType": "application/vnd.microsoft.teams.card.list", "content": { diff --git a/Examples/ListCard/CardList-Native.ps1 b/Examples/ListCard/CardList-Native.ps1 index e68b954..adec3f3 100644 --- a/Examples/ListCard/CardList-Native.ps1 +++ b/Examples/ListCard/CardList-Native.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') New-CardList { New-CardListItem -Type file -Title 'Report' -SubTitle 'teams > new > design' -TapType openUrl -TapValue "https://contoso.sharepoint.com/teams/new/Shared%20Documents/Report.xlsx" -TapAction editOnline diff --git a/Examples/MessageCard/MessageCard-GraphChat.ps1 b/Examples/MessageCard/MessageCard-GraphChat.ps1 new file mode 100644 index 0000000..8ff2c33 --- /dev/null +++ b/Examples/MessageCard/MessageCard-GraphChat.ps1 @@ -0,0 +1,11 @@ +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42 stopped in the release stage.' -Summary 'Build summary' +$target = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessTokenVariableName 'TEAMSX_GRAPH_TOKEN' -DisplayName 'Ops Chat' + +Send-TeamsMessage -Message $message -Target $target + +# Graph chat and channel messages currently use HTML body rendering for plain text/message-card content, +# or adaptive-card attachments when -AdaptiveCard is present. Adaptive cards should stick to Action.OpenUrl for now. +# You can also use -SecureAccessToken instead of -AccessTokenVariableName when you already have a SecureString token. +# For normal posting, use a delegated Graph access token. Microsoft documents application permissions here as migration-only. diff --git a/Examples/MessageCard/MessageCard-Typed.ps1 b/Examples/MessageCard/MessageCard-Typed.ps1 new file mode 100644 index 0000000..c56c9e2 --- /dev/null +++ b/Examples/MessageCard/MessageCard-Typed.ps1 @@ -0,0 +1,17 @@ +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42 stopped in the release stage.' -Summary 'Build summary' -Color DodgerBlue -HideOriginalBody -Sections @( + New-TeamsSection -Title 'Build summary' -ActivityTitle 'Release pipeline' -ActivitySubtitle 'Run 42' -ActivityText 'Deployment stopped after test failures.' -ActivityDetails @( + New-TeamsFact -Name 'Status' -Value 'Failed' + New-TeamsFact -Name 'Environment' -Value 'Production' + New-TeamsFact -Name 'Owner' -Value 'Platform Team' + ) -Buttons @( + New-TeamsButton -Name 'Open build' -Link 'https://example.test/build/42' -Type OpenUri + ) +) + +$json = $message | ConvertTo-TeamsJson +$json | ConvertFrom-Json | ConvertTo-Json -Depth 20 + +# $target = New-TeamsWebhookTarget -Uri $Env:TEAMSPESTERID +# Send-TeamsMessage -Message $message -Target $target diff --git a/Examples/MessageCard/WrapperCard-Typed.ps1 b/Examples/MessageCard/WrapperCard-Typed.ps1 new file mode 100644 index 0000000..335785e --- /dev/null +++ b/Examples/MessageCard/WrapperCard-Typed.ps1 @@ -0,0 +1,30 @@ +. $PSScriptRoot\..\Import-PSTeams.ps1 + +$target = New-TeamsWebhookTarget -Uri 'https://example.test/webhook' + +$heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text 'Monorail text' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' -AlternateText 'Monorail' +) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' +) + +$thumbnailCard = New-TeamsThumbnailCard -Title 'Bender' -SubTitle 'robot' -Text 'Futurama' -Images @( + New-TeamsCardImage -Url 'https://example.test/bender.png' -AlternateText 'Bender' +) -Buttons @( + New-CardListButton -Type ImBack -Title 'Thumbs Up' -Value 'I like it' +) + +$listCard = New-TeamsListCard -Title 'Card Title' -Items @( + New-CardListItem -Type File -Title 'Report' -SubTitle 'teams > new > design' -TapType OpenUrl -TapValue 'https://contoso.example/report.xlsx' -TapAction editOnline + New-CardListItem -Type Person -Title 'John Doe' -SubTitle 'Manager' -TapType ImBack -TapValue 'JohnDoe@contoso.com' -TapAction whois +) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Show' -Value 'https://evotec.xyz' +) + +# Send-TeamsMessage -HeroCard $heroCard -Target $target +# Send-TeamsMessage -ThumbnailCard $thumbnailCard -Target $target +# Send-TeamsMessage -ListCard $listCard -Target $target + +$heroCard | ConvertTo-TeamsJson +$thumbnailCard | ConvertTo-TeamsJson +$listCard | ConvertTo-TeamsJson diff --git a/Examples/ThumbnailCard/ThumbnailCard-FromJson.ps1 b/Examples/ThumbnailCard/ThumbnailCard-FromJson.ps1 index 700cfc6..006318c 100644 --- a/Examples/ThumbnailCard/ThumbnailCard-FromJson.ps1 +++ b/Examples/ThumbnailCard/ThumbnailCard-FromJson.ps1 @@ -1,4 +1,6 @@ -$Wrapper = @" +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') + +$Wrapper = @" { "contentType": "application/vnd.microsoft.card.thumbnail", "content": { diff --git a/Examples/ThumbnailCard/ThumbnailCard-Native.ps1 b/Examples/ThumbnailCard/ThumbnailCard-Native.ps1 index cf2f19f..fb1d057 100644 --- a/Examples/ThumbnailCard/ThumbnailCard-Native.ps1 +++ b/Examples/ThumbnailCard/ThumbnailCard-Native.ps1 @@ -1,4 +1,4 @@ -Import-Module .\PSTeams.psd1 -Force +. (Join-Path $PSScriptRoot '..\Import-PSTeams.ps1') # Please notice that # - Images are not supported in buttons, you can send them but it's not displayed diff --git a/Module/Build/Build-Module.ps1 b/Module/Build/Build-Module.ps1 index bfdd137..2a73b09 100644 --- a/Module/Build/Build-Module.ps1 +++ b/Module/Build/Build-Module.ps1 @@ -1,87 +1 @@ -Import-Module PSPublishModule -Force -ErrorAction Stop - -$refreshPSD1Only = $false -if ($env:RefreshPSD1Only) { - $refreshPSD1Only = [System.Convert]::ToBoolean($env:RefreshPSD1Only) -} - -Build-Module -ModuleName 'TeamsX' { - $Manifest = [ordered] @{ - ModuleVersion = '0.1.X' - CompatiblePSEditions = @('Desktop', 'Core') - GUID = '2ce3429c-d55a-4d79-97d2-a4ac17549936' - Author = 'Przemyslaw Klys' - CompanyName = 'Evotec' - Copyright = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved." - Description = 'TeamsX is a binary-first PowerShell module for composing and sending Microsoft Teams messages using typed C# cmdlets over the TeamsX .NET library.' - Tags = @('Teams', 'Microsoft', 'MSTeams', 'Notifications', 'PowerShell', 'Windows', 'MacOS', 'Linux') - IconUri = 'https://statics.teams.microsoft.com/evergreen-assets/apps/teamscmdlets_largeimage.png' - ProjectUri = 'https://github.com/EvotecIT/PSTeams' - PowerShellVersion = '5.1' - } - New-ConfigurationManifest @Manifest - - $configurationFormat = [ordered] @{ - RemoveComments = $false - PlaceOpenBraceEnable = $true - PlaceOpenBraceOnSameLine = $true - PlaceOpenBraceNewLineAfter = $true - PlaceOpenBraceIgnoreOneLineBlock = $false - PlaceCloseBraceEnable = $true - PlaceCloseBraceNewLineAfter = $false - PlaceCloseBraceIgnoreOneLineBlock = $false - PlaceCloseBraceNoEmptyLineBefore = $true - UseConsistentIndentationEnable = $true - UseConsistentIndentationKind = 'space' - UseConsistentIndentationPipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' - UseConsistentIndentationIndentationSize = 4 - UseConsistentWhitespaceEnable = $true - UseConsistentWhitespaceCheckInnerBrace = $true - UseConsistentWhitespaceCheckOpenBrace = $true - UseConsistentWhitespaceCheckOpenParen = $true - UseConsistentWhitespaceCheckOperator = $true - UseConsistentWhitespaceCheckPipe = $true - UseConsistentWhitespaceCheckSeparator = $true - AlignAssignmentStatementEnable = $true - AlignAssignmentStatementCheckHashtable = $true - UseCorrectCasingEnable = $true - } - - New-ConfigurationFormat -ApplyTo 'OnMergePSM1', 'OnMergePSD1' -Sort None @configurationFormat - New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'DefaultPSM1' -EnableFormatting -Sort None - New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal' - - New-ConfigurationDocumentation -Enable:$false -StartClean -UpdateWhenNew -PathReadme 'Docs\Readme.md' -Path 'Docs' - New-ConfigurationImportModule -ImportSelf -ImportRequiredModules - - $newConfigurationBuildSplat = @{ - Enable = $true - SignModule = $true - MergeModuleOnBuild = $true - MergeFunctionsFromApprovedModules = $true - CertificateThumbprint = '483292C9E317AA13B07BB7A96AE9D1A5ED9E7703' - NETProjectPath = "$PSScriptRoot\..\..\TeamsX.PowerShell" - ResolveBinaryConflicts = $true - ResolveBinaryConflictsName = 'TeamsX.PowerShell' - NETProjectName = 'TeamsX.PowerShell' - NETBinaryModule = 'TeamsX.PowerShell.dll' - NETConfiguration = 'Release' - # PSPublishModule maps all modern .NET targets into Lib\Core. Keep the packaged - # module on net8.0 for PowerShell compatibility while the projects themselves - # still build and test on net10.0. - NETFramework = 'net472', 'net8.0' - DotSourceLibraries = $true - NETSearchClass = 'TeamsX.PowerShell.CmdletSendTeamsMessage' - NETBinaryModuleDocumentation = $true - RefreshPSD1Only = $refreshPSD1Only - } - - New-ConfigurationBuild @newConfigurationBuildSplat - - New-ConfigurationArtefact -Type Unpacked -Enable -Path "$PSScriptRoot\..\Artefacts\Unpacked" -RequiredModulesPath "$PSScriptRoot\..\Artefacts\Unpacked\Modules" - New-ConfigurationArtefact -Type Packed -Enable -Path "$PSScriptRoot\..\Artefacts\Packed" -IncludeTagName -ArtefactName "TeamsX-PowerShellModule..zip" -ID 'ToGitHub' - - # global options for publishing to github/psgallery - #New-ConfigurationPublish -Type PowerShellGallery -FilePath 'C:\Support\Important\PowerShellGalleryAPI.txt' -Enabled:$true - #New-ConfigurationPublish -Type GitHub -FilePath 'C:\Support\Important\GitHubAPI.txt' -UserName 'EvotecIT' -Enabled:$true -ID 'ToGitHub' -OverwriteTagName 'TeamsX-PowerShellModule.' -} +. "$PSScriptRoot\..\PSTeams\Build\Build-Module.ps1" diff --git a/Module/PSTeams/Build/Build-Module.ps1 b/Module/PSTeams/Build/Build-Module.ps1 new file mode 100644 index 0000000..561faea --- /dev/null +++ b/Module/PSTeams/Build/Build-Module.ps1 @@ -0,0 +1,84 @@ +Import-Module PSPublishModule -Force -ErrorAction Stop + +$refreshPSD1Only = $false +if ($env:RefreshPSD1Only) { + $refreshPSD1Only = [System.Convert]::ToBoolean($env:RefreshPSD1Only) +} + +Build-Module -ModuleName 'PSTeams' { + $Manifest = [ordered] @{ + ModuleVersion = '2.4.X' + CompatiblePSEditions = @('Desktop', 'Core') + GUID = 'a46c3b0b-5687-4d62-89c5-753ae01e0926' + Author = 'Przemyslaw Klys' + CompanyName = 'Evotec' + Copyright = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved." + Description = 'PSTeams is being migrated 1:1 from PowerShell functions to C# cmdlets over the reusable TeamsX .NET library while the shipping module shell stays in Module\PSTeams.' + Tags = @('Teams', 'Microsoft', 'MSTeams', 'Notifications', 'Webhook', 'PowerShell', 'Windows', 'MacOS', 'Linux') + IconUri = 'https://statics.teams.microsoft.com/evergreen-assets/apps/teamscmdlets_largeimage.png' + ProjectUri = 'https://github.com/EvotecIT/PSTeams' + PowerShellVersion = '5.1' + } + New-ConfigurationManifest @Manifest + + $configurationFormat = [ordered] @{ + RemoveComments = $false + PlaceOpenBraceEnable = $true + PlaceOpenBraceOnSameLine = $true + PlaceOpenBraceNewLineAfter = $true + PlaceOpenBraceIgnoreOneLineBlock = $false + PlaceCloseBraceEnable = $true + PlaceCloseBraceNewLineAfter = $false + PlaceCloseBraceIgnoreOneLineBlock = $false + PlaceCloseBraceNoEmptyLineBefore = $true + UseConsistentIndentationEnable = $true + UseConsistentIndentationKind = 'space' + UseConsistentIndentationPipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + UseConsistentIndentationIndentationSize = 4 + UseConsistentWhitespaceEnable = $true + UseConsistentWhitespaceCheckInnerBrace = $true + UseConsistentWhitespaceCheckOpenBrace = $true + UseConsistentWhitespaceCheckOpenParen = $true + UseConsistentWhitespaceCheckOperator = $true + UseConsistentWhitespaceCheckPipe = $true + UseConsistentWhitespaceCheckSeparator = $true + AlignAssignmentStatementEnable = $true + AlignAssignmentStatementCheckHashtable = $true + UseCorrectCasingEnable = $true + } + + New-ConfigurationFormat -ApplyTo 'OnMergePSM1', 'OnMergePSD1' -Sort None @configurationFormat + New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'DefaultPSM1' -EnableFormatting -Sort None + New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal' + + New-ConfigurationDocumentation -Enable:$false -StartClean -UpdateWhenNew -PathReadme 'Docs\Readme.md' -Path 'Docs' + New-ConfigurationImportModule -ImportSelf -ImportRequiredModules + + $newConfigurationBuildSplat = @{ + Enable = $true + SignModule = $true + MergeModuleOnBuild = $true + MergeFunctionsFromApprovedModules = $true + CertificateThumbprint = '483292C9E317AA13B07BB7A96AE9D1A5ED9E7703' + NETProjectPath = "$PSScriptRoot\..\..\..\TeamsX.PowerShell" + ResolveBinaryConflicts = $true + ResolveBinaryConflictsName = 'TeamsX.PowerShell' + NETProjectName = 'TeamsX.PowerShell' + NETBinaryModule = 'TeamsX.PowerShell.dll' + NETConfiguration = 'Release' + NETFramework = 'net472', 'net8.0', 'net10.0' + DotSourceLibraries = $true + NETSearchClass = 'TeamsX.PowerShell.CmdletSendTeamsMessage' + NETBinaryModuleDocumentation = $true + RefreshPSD1Only = $refreshPSD1Only + } + + New-ConfigurationBuild @newConfigurationBuildSplat + + New-ConfigurationArtefact -Type Unpacked -Enable -Path "$PSScriptRoot\..\..\Artefacts\Unpacked" -RequiredModulesPath "$PSScriptRoot\..\..\Artefacts\Unpacked\Modules" + New-ConfigurationArtefact -Type Packed -Enable -Path "$PSScriptRoot\..\..\Artefacts\Packed" -IncludeTagName -ArtefactName "PSTeams-PowerShellModule..zip" -ID 'ToGitHub' + + # global options for publishing to github/psgallery + #New-ConfigurationPublish -Type PowerShellGallery -FilePath 'C:\Support\Important\PowerShellGalleryAPI.txt' -Enabled:$true + #New-ConfigurationPublish -Type GitHub -FilePath 'C:\Support\Important\GitHubAPI.txt' -UserName 'EvotecIT' -Enabled:$true -ID 'ToGitHub' -OverwriteTagName 'PSTeams-PowerShellModule.' +} diff --git a/Images/add.jpg b/Module/PSTeams/Images/add.jpg similarity index 100% rename from Images/add.jpg rename to Module/PSTeams/Images/add.jpg diff --git a/Images/alert.jpg b/Module/PSTeams/Images/alert.jpg similarity index 100% rename from Images/alert.jpg rename to Module/PSTeams/Images/alert.jpg diff --git a/Images/cancel.jpg b/Module/PSTeams/Images/cancel.jpg similarity index 100% rename from Images/cancel.jpg rename to Module/PSTeams/Images/cancel.jpg diff --git a/Images/check.jpg b/Module/PSTeams/Images/check.jpg similarity index 100% rename from Images/check.jpg rename to Module/PSTeams/Images/check.jpg diff --git a/Images/disable.jpg b/Module/PSTeams/Images/disable.jpg similarity index 100% rename from Images/disable.jpg rename to Module/PSTeams/Images/disable.jpg diff --git a/Images/download.jpg b/Module/PSTeams/Images/download.jpg similarity index 100% rename from Images/download.jpg rename to Module/PSTeams/Images/download.jpg diff --git a/Images/info.jpg b/Module/PSTeams/Images/info.jpg similarity index 100% rename from Images/info.jpg rename to Module/PSTeams/Images/info.jpg diff --git a/Images/minus.jpg b/Module/PSTeams/Images/minus.jpg similarity index 100% rename from Images/minus.jpg rename to Module/PSTeams/Images/minus.jpg diff --git a/Images/question.jpg b/Module/PSTeams/Images/question.jpg similarity index 100% rename from Images/question.jpg rename to Module/PSTeams/Images/question.jpg diff --git a/Images/reload.jpg b/Module/PSTeams/Images/reload.jpg similarity index 100% rename from Images/reload.jpg rename to Module/PSTeams/Images/reload.jpg diff --git a/Module/License b/Module/PSTeams/License similarity index 100% rename from Module/License rename to Module/PSTeams/License diff --git a/PSTeams.Tests.ps1 b/Module/PSTeams/PSTeams.Tests.ps1 similarity index 88% rename from PSTeams.Tests.ps1 rename to Module/PSTeams/PSTeams.Tests.ps1 index da9196c..2166182 100644 --- a/PSTeams.Tests.ps1 +++ b/Module/PSTeams/PSTeams.Tests.ps1 @@ -56,8 +56,13 @@ try { Write-Color 'Running tests...' -Color Yellow Write-Color -$result = Invoke-Pester -Script $PSScriptRoot\Tests -Verbose -PassThru +$testsPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Tests' +if (-not (Test-Path -LiteralPath $testsPath)) { + throw "Path $testsPath doesn't contain the active PSTeams migration tests. Failing tests." +} + +$result = Invoke-Pester -Script $testsPath -Verbose -PassThru if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) tests failed." -} \ No newline at end of file +} diff --git a/Module/PSTeams/PSTeams.psd1 b/Module/PSTeams/PSTeams.psd1 new file mode 100644 index 0000000..c3fc108 --- /dev/null +++ b/Module/PSTeams/PSTeams.psd1 @@ -0,0 +1,26 @@ +@{ + RootModule = 'PSTeams.psm1' + ModuleVersion = '2.4.1' + GUID = 'a46c3b0b-5687-4d62-89c5-753ae01e0926' + Author = 'Przemyslaw Klys' + CompanyName = 'Evotec' + Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' + Description = 'PSTeams is being migrated 1:1 from PowerShell functions to C# cmdlets over the reusable TeamsX .NET library while the shipping module shell stays in Module\PSTeams.' + PowerShellVersion = '5.1' + CompatiblePSEditions = @('Desktop', 'Core') + FunctionsToExport = @() + CmdletsToExport = @('ConvertTo-TeamsFact', 'ConvertTo-TeamsJson', 'ConvertTo-TeamsSection', 'New-AdaptiveAction', 'New-AdaptiveActionSet', 'New-AdaptiveCard', 'New-AdaptiveColumn', 'New-AdaptiveColumnSet', 'New-AdaptiveContainer', 'New-AdaptiveFact', 'New-AdaptiveFactSet', 'New-AdaptiveImage', 'New-AdaptiveImageSet', 'New-AdaptiveLineBreak', 'New-AdaptiveMedia', 'New-AdaptiveMediaSource', 'New-AdaptiveMention', 'New-AdaptiveRichTextBlock', 'New-AdaptiveTable', 'New-AdaptiveTextBlock', 'New-CardList', 'New-CardListButton', 'New-CardListItem', 'New-HeroCard', 'New-TeamsAdaptiveActionSet', 'New-TeamsAdaptiveCard', 'New-TeamsAdaptiveColumn', 'New-TeamsAdaptiveColumnSet', 'New-TeamsAdaptiveContainer', 'New-TeamsAdaptiveFact', 'New-TeamsAdaptiveFactSet', 'New-TeamsAdaptiveImage', 'New-TeamsAdaptiveImageSet', 'New-TeamsAdaptiveMedia', 'New-TeamsAdaptiveMediaSource', 'New-TeamsAdaptiveMention', 'New-TeamsAdaptiveOpenUrlAction', 'New-TeamsAdaptiveRichTextBlock', 'New-TeamsAdaptiveShowCardAction', 'New-TeamsAdaptiveSubmitAction', 'New-TeamsAdaptiveTextBlock', 'New-TeamsAdaptiveTextRun', 'New-TeamsAdaptiveToggleVisibilityAction', 'New-TeamsActivityImage', 'New-TeamsActivitySubtitle', 'New-TeamsActivityText', 'New-TeamsActivityTitle', 'New-TeamsBigImage', 'New-TeamsButton', 'New-TeamsCardImage', 'New-TeamsFact', 'New-TeamsGraphTarget', 'New-TeamsHeroCard', 'New-TeamsImage', 'New-TeamsList', 'New-TeamsListCard', 'New-TeamsListItem', 'New-TeamsMessage', 'New-TeamsSection', 'New-TeamsThumbnailCard', 'New-TeamsWebhookTarget', 'New-ThumbnailCard', 'Send-TeamsMessage', 'Send-TeamsMessageBody') + AliasesToExport = @('New-HeroImage', 'New-ThumbnailImage', 'New-AdaptiveImageGallery', 'New-HeroButton', 'New-ThumbnailButton', 'ActivityImageLink', 'TeamsActivityImageLink', 'New-TeamsActivityImageLink', 'ActivityImage', 'TeamsActivityImage', 'ActivitySubtitle', 'TeamsActivitySubtitle', 'ActivityText', 'TeamsActivityText', 'ActivityTitle', 'TeamsActivityTitle', 'TeamsBigImage', 'TeamsButton', 'TeamsFact', 'TeamsImage', 'TeamsList', 'TeamsListItem', 'TeamsSection', 'TeamsMessage', 'TeamsMessageBody') + PrivateData = @{ + PSData = @{ + Tags = @('Teams', 'Microsoft', 'MSTeams', 'Notifications', 'Webhook', 'PowerShell', 'Windows', 'MacOS', 'Linux') + ProjectUri = 'https://github.com/EvotecIT/PSTeams' + ReleaseNotes = 'Main branch now ships the PSTeams module shell from Module\PSTeams and migrates functionality incrementally to TeamsX-based cmdlets.' + IconUri = 'https://statics.teams.microsoft.com/evergreen-assets/apps/teamscmdlets_largeimage.png' + RequireLicenseAcceptance = $false + ExternalModuleDependencies = @() + } + } + RequiredModules = @() + ScriptsToProcess = @() +} diff --git a/Module/PSTeams/PSTeams.psm1 b/Module/PSTeams/PSTeams.psm1 new file mode 100644 index 0000000..26b833c --- /dev/null +++ b/Module/PSTeams/PSTeams.psm1 @@ -0,0 +1,62 @@ +$binaryName = 'TeamsX.PowerShell.dll' +$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' +$preferredFolders = if ($PSEdition -eq 'Core') { + $runtimeMajor = [System.Environment]::Version.Major + if ($runtimeMajor -ge 10) { + @('net10.0', 'net8.0', 'netstandard2.0') + } else { + @('net8.0', 'netstandard2.0') + } +} else { + @('net472', 'netstandard2.0') +} + +$modulePath = $null +foreach ($folder in $preferredFolders) { + $candidate = Join-Path -Path $developmentPath -ChildPath "$folder\$binaryName" + if (Test-Path -LiteralPath $candidate) { + $modulePath = $candidate + break + } +} + +if (-not $modulePath) { + $libFolder = if ($PSEdition -eq 'Core') { 'Core' } else { 'Default' } + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath "Lib\$libFolder\$binaryName" +} + +Import-Module -Name $modulePath -Force -ErrorAction Stop + +$binaryAliases = @{ + ActivityImage = 'New-TeamsActivityImage' + ActivityImageLink = 'New-TeamsActivityImage' + ActivitySubtitle = 'New-TeamsActivitySubtitle' + ActivityText = 'New-TeamsActivityText' + ActivityTitle = 'New-TeamsActivityTitle' + 'New-AdaptiveImageGallery' = 'New-AdaptiveImageSet' + 'New-HeroButton' = 'New-CardListButton' + 'New-HeroImage' = 'New-AdaptiveImage' + 'New-TeamsActivityImageLink' = 'New-TeamsActivityImage' + 'New-ThumbnailButton' = 'New-CardListButton' + 'New-ThumbnailImage' = 'New-AdaptiveImage' + TeamsActivityImage = 'New-TeamsActivityImage' + TeamsActivityImageLink = 'New-TeamsActivityImage' + TeamsActivitySubtitle = 'New-TeamsActivitySubtitle' + TeamsActivityText = 'New-TeamsActivityText' + TeamsActivityTitle = 'New-TeamsActivityTitle' + TeamsBigImage = 'New-TeamsBigImage' + TeamsButton = 'New-TeamsButton' + TeamsFact = 'New-TeamsFact' + TeamsImage = 'New-TeamsImage' + TeamsList = 'New-TeamsList' + TeamsListItem = 'New-TeamsListItem' + TeamsSection = 'New-TeamsSection' + TeamsMessage = 'Send-TeamsMessage' + TeamsMessageBody = 'Send-TeamsMessageBody' +} + +foreach ($alias in $binaryAliases.GetEnumerator()) { + Set-Alias -Name $alias.Key -Value $alias.Value -Scope Local +} + +Export-ModuleMember -Alias * -Cmdlet * diff --git a/Module/TeamsX.psd1 b/Module/TeamsX.psd1 deleted file mode 100644 index 51ee8ed..0000000 --- a/Module/TeamsX.psd1 +++ /dev/null @@ -1,26 +0,0 @@ -@{ - RootModule = 'TeamsX.psm1' - ModuleVersion = '0.1.0' - GUID = '2ce3429c-d55a-4d79-97d2-a4ac17549936' - Author = 'Przemyslaw Klys' - CompanyName = 'Evotec' - Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' - Description = 'TeamsX is a binary-first PowerShell module for composing and sending Microsoft Teams messages using typed C# cmdlets over the TeamsX .NET library.' - PowerShellVersion = '5.1' - CompatiblePSEditions = @('Desktop', 'Core') - FunctionsToExport = @() - CmdletsToExport = @('ConvertTo-TeamsJson', 'New-TeamsAdaptiveActionSet', 'New-TeamsAdaptiveCard', 'New-TeamsAdaptiveColumn', 'New-TeamsAdaptiveColumnSet', 'New-TeamsAdaptiveContainer', 'New-TeamsAdaptiveFact', 'New-TeamsAdaptiveFactSet', 'New-TeamsAdaptiveImage', 'New-TeamsAdaptiveImageSet', 'New-TeamsAdaptiveMedia', 'New-TeamsAdaptiveMediaSource', 'New-TeamsAdaptiveMention', 'New-TeamsAdaptiveOpenUrlAction', 'New-TeamsAdaptiveRichTextBlock', 'New-TeamsAdaptiveTextBlock', 'New-TeamsAdaptiveTextRun', 'New-TeamsAdaptiveToggleVisibilityAction', 'New-TeamsMessage', 'New-TeamsWebhookTarget', 'Send-TeamsMessage') - AliasesToExport = @() - PrivateData = @{ - PSData = @{ - Tags = @('Teams', 'Microsoft', 'MSTeams', 'Notifications', 'PowerShell', 'Windows', 'MacOS', 'Linux') - ProjectUri = 'https://github.com/EvotecIT/PSTeams' - ReleaseNotes = 'Binary-first TeamsX PowerShell module for the main branch.' - IconUri = 'https://statics.teams.microsoft.com/evergreen-assets/apps/teamscmdlets_largeimage.png' - RequireLicenseAcceptance = $false - ExternalModuleDependencies = @() - } - } - RequiredModules = @() - ScriptsToProcess = @() -} diff --git a/Module/TeamsX.psm1 b/Module/TeamsX.psm1 deleted file mode 100644 index ab41918..0000000 --- a/Module/TeamsX.psm1 +++ /dev/null @@ -1,24 +0,0 @@ -$binaryName = "TeamsX.PowerShell.dll" -$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath "..\TeamsX.PowerShell\bin\Debug" -$preferredFolders = if ($PSEdition -eq "Core") { - @("net8.0", "net10.0", "netstandard2.0") -} else { - @("net472", "netstandard2.0") -} - -$modulePath = $null -foreach ($folder in $preferredFolders) { - $candidate = Join-Path -Path $developmentPath -ChildPath "$folder\$binaryName" - if (Test-Path -LiteralPath $candidate) { - $modulePath = $candidate - break - } -} - -if (-not $modulePath) { - $libFolder = if ($PSEdition -eq "Core") { "Core" } else { "Default" } - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath "Lib\$libFolder\$binaryName" -} - -Import-Module -Name $modulePath -Force -ErrorAction Stop -Export-ModuleMember -Cmdlet "*" diff --git a/Module/Tests/Binary.Compose.Tests.ps1 b/Module/Tests/Binary.Compose.Tests.ps1 index c2976c0..3f59718 100644 --- a/Module/Tests/Binary.Compose.Tests.ps1 +++ b/Module/Tests/Binary.Compose.Tests.ps1 @@ -1,18 +1,28 @@ -Describe 'TeamsX binary cmdlets' { +Describe 'TeamsX binary cmdlets through PSTeams' { BeforeEach { - Get-Module TeamsX, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue + Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue } It 'renders adaptive card JSON from typed cmdlets only' { - Import-Module "$PSScriptRoot\..\TeamsX.psd1" -Force + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force $richText = New-TeamsAdaptiveRichTextBlock -Inlines @( New-TeamsAdaptiveTextRun -Text 'Run ' -Color Default New-TeamsAdaptiveTextRun -Text '42' -Weight Bolder -Color Attention - ) + ) -Id 'summary' -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Hidden $body = @( New-TeamsAdaptiveTextBlock -Text 'Build failed' -Weight Bolder -Color Attention + New-TeamsAdaptiveContainer -Style Emphasis -Bleed -MinimumHeight 120 -HorizontalAlignment Center -VerticalContentAlignment center -Height Stretch -Spacing Medium -Separator -Id 'panel' -BackgroundUrl 'https://example.test/background.png' -BackgroundFillMode Cover -BackgroundHorizontalAlignment left -BackgroundVerticalAlignment top -SelectActionUrl 'https://example.test/panel' -SelectActionTitle 'Open panel' -Items @( + New-TeamsAdaptiveColumnSet -Style Good -Bleed -MinimumHeight 80 -HorizontalAlignment Center -Height Stretch -Spacing Medium -Separator -Columns @( + New-TeamsAdaptiveColumn -WidthInWeight 2 -MinimumHeight 90 -HorizontalAlignment Right -VerticalContentAlignment Bottom -Spacing Small -Style Attention -Separator -Hidden -SelectAction 'Action.ToggleVisibility' -SelectActionId 'toggle-column' -SelectActionTitle 'Toggle column' -SelectActionTargetElement 'detailsBlock' -Items @( + New-TeamsAdaptiveTextBlock -Text 'Weighted column' + ) + New-TeamsAdaptiveColumn -Width 'auto' -Items @( + New-TeamsAdaptiveImage -Url 'https://example.test/status.png' -AltText 'Status' + ) + ) + ) $richText New-TeamsAdaptiveImageSet -ImageSize Medium -Images @( New-TeamsAdaptiveImage -Url 'https://example.test/image-1.png' -AltText 'First' @@ -25,28 +35,45 @@ Describe 'TeamsX binary cmdlets' { $actions = @( New-TeamsAdaptiveOpenUrlAction -Title 'Open build' -Url 'https://example.test/build/42' + New-TeamsAdaptiveSubmitAction -Title 'Acknowledge' New-TeamsAdaptiveToggleVisibilityAction -Title 'Toggle details' -TargetElementIds 'detailsBlock', 'detailsFactSet' + New-TeamsAdaptiveShowCardAction -Title 'More details' -Body @( + New-TeamsAdaptiveTextBlock -Text 'Nested details' + ) -Actions @( + New-TeamsAdaptiveSubmitAction -Title 'Nested acknowledge' + ) ) $mentions = @( New-TeamsAdaptiveMention -Text 'Ops Team' -UserPrincipalName 'ops@example.test' -Name 'Ops Team' ) - $card = New-TeamsAdaptiveCard -Body $body -Actions $actions -Mentions $mentions + $card = New-TeamsAdaptiveCard -Body $body -Actions $actions -Mentions $mentions -FallbackText 'Fallback text' -MinimumHeight 140 -Speak 'Build failed' -Language 'en' -VerticalContentAlignment center -BackgroundUrl 'https://example.test/card-background.png' -BackgroundFillMode Cover -BackgroundHorizontalAlignment left -BackgroundVerticalAlignment top -SelectActionUrl 'https://example.test/card' -SelectActionTitle 'Open card' -AllowImageExpand -FullWidth $message = New-TeamsMessage -Summary 'Build notification' -AdaptiveCard $card $json = $message | ConvertTo-TeamsJson $json | Should -Match '"type":"AdaptiveCard"' + $json | Should -Match '"fallbackText":"Fallback text"' + $json | Should -Match '"minHeight":"140px"' + $json | Should -Match '"allowExpand":true' + $json | Should -Match '"width":"Full"' + $json | Should -Match '"type":"Container"' + $json | Should -Match '"type":"ColumnSet"' + $json | Should -Match '"type":"Column"' + $json | Should -Match '"targetElements":\["detailsBlock"\]' $json | Should -Match '"type":"ImageSet"' $json | Should -Match '"type":"Media"' $json | Should -Match '"type":"RichTextBlock"' + $json | Should -Match '"type":"Action.Submit"' + $json | Should -Match '"type":"Action.ShowCard"' + $json | Should -Match '"card":\{"\$schema":"http://adaptivecards.io/schemas/adaptive-card.json"' $json | Should -Match '"type":"Action.ToggleVisibility"' $json | Should -Match '"type":"mention"' $json | Should -Match '"url":"https://example.test/build/42"' } It 'creates standard and workflow webhook targets' { - Import-Module "$PSScriptRoot\..\TeamsX.psd1" -Force + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force $incoming = New-TeamsWebhookTarget -Uri 'https://example.test/incoming' $workflow = New-TeamsWebhookTarget -Uri 'https://example.test/workflow' -Workflow @@ -55,12 +82,138 @@ Describe 'TeamsX binary cmdlets' { $workflow.DeliveryMethod.ToString() | Should -Be 'WorkflowWebhook' } + It 'creates graph channel and chat targets' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $channel = New-TeamsGraphTarget -TeamId 'team-42' -ChannelId 'channel-99' -AccessToken 'token-1' -DisplayName 'Release channel' -GraphBaseUri 'https://graph.example.test/' + $chat = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessToken 'token-2' -DisplayName 'Ops chat' -GraphBaseUri 'https://graph.example.test/' + + $channel.DeliveryMethod.ToString() | Should -Be 'GraphChannelMessage' + $channel.TargetUri.ToString() | Should -Be 'https://graph.example.test/v1.0/teams/team-42/channels/channel-99/messages' + $chat.DeliveryMethod.ToString() | Should -Be 'GraphChatMessage' + $chat.TargetUri.ToString() | Should -Be 'https://graph.example.test/v1.0/chats/19%3Atestchat%40thread.v2/messages' + } + + It 'creates graph targets backed by environment variables and secure strings' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $env:TEAMSX_GRAPH_TOKEN = 'token-from-env' + try { + $secureToken = ConvertTo-SecureString 'token-from-secure-string' -AsPlainText -Force + + $fromEnv = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessTokenVariableName 'TEAMSX_GRAPH_TOKEN' + $fromSecure = New-TeamsGraphTarget -TeamId 'team-42' -ChannelId 'channel-99' -SecureAccessToken $secureToken + + $fromEnv.HasDynamicAccessToken | Should -BeTrue + $fromEnv.AccessToken | Should -BeNullOrEmpty + $fromSecure.HasDynamicAccessToken | Should -BeTrue + $fromSecure.AccessToken | Should -BeNullOrEmpty + } finally { + Remove-Item Env:\TEAMSX_GRAPH_TOKEN -ErrorAction SilentlyContinue + } + } + + It 'renders connector-card JSON from typed Teams message cmdlets' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42' -Summary 'Build summary' -Color DodgerBlue -HideOriginalBody -Sections @( + New-TeamsSection -Title 'Build summary' -ActivityText 'Pipeline failed' -ActivityDetails @( + New-TeamsFact -Name 'Status' -Value 'Failed' + ) -Buttons @( + New-TeamsButton -Name 'Open build' -Link 'https://example.test/build/42' -Type OpenUri + ) + ) + + $json = $message | ConvertTo-TeamsJson + + $json | Should -Match '"themeColor":"#1E90FF"' + $json | Should -Match '"hideOriginalBody":true' + $json | Should -Match '"title":"Build failed"' + $json | Should -Match '"sections":\[' + $json | Should -Match '"name":"Status"' + $json | Should -Match '"@type":"OpenURI"' + } + + It 'renders typed wrapper-card objects through ConvertTo-TeamsJson' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text 'Monorail text' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' -AlternateText 'Monorail' + ) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' + ) + $thumbnailCard = New-TeamsThumbnailCard -Title 'Bender' -SubTitle 'robot' -Text 'Futurama' -Images @( + New-TeamsCardImage -Url 'https://example.test/bender.png' -AlternateText 'Bender' + ) -Buttons @( + New-CardListButton -Type ImBack -Title 'Thumbs Up' -Value 'I like it' + ) + $listCard = New-TeamsListCard -Title 'Card Title' -Items @( + New-CardListItem -Type File -Title 'Report' -SubTitle 'teams > new > design' -TapType OpenUrl -TapValue 'https://contoso.example/report.xlsx' -TapAction editOnline + New-CardListItem -Type Person -Title 'John Doe' -SubTitle 'Manager' -TapType ImBack -TapValue 'JohnDoe@contoso.com' -TapAction whois + ) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Show' -Value 'https://evotec.xyz' + ) + + $heroJson = $heroCard | ConvertTo-TeamsJson + $thumbnailJson = $thumbnailCard | ConvertTo-TeamsJson + $listJson = $listCard | ConvertTo-TeamsJson + + $heroJson | Should -Match '"contentType":"application/vnd.microsoft.card.hero"' + $heroJson | Should -Match '"alt":"Monorail"' + $thumbnailJson | Should -Match '"contentType":"application/vnd.microsoft.card.thumbnail"' + $thumbnailJson | Should -Match '"type":"imBack"' + $listJson | Should -Match '"contentType":"application/vnd.microsoft.teams.card.list"' + $listJson | Should -Match '"value":"editOnline https://contoso.example/report.xlsx"' + $listJson | Should -Match '"value":"whois JohnDoe@contoso.com"' + } + + It 'wraps typed wrapper-card JSON through Send-TeamsMessageBody' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' + ) + $wrapped = $heroCard | + ConvertTo-TeamsJson | + Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Wrap -Supress:$false -WhatIf + + $wrapped | Should -Match '"type":"message"' + $wrapped | Should -Match '"attachments":\[' + $wrapped | Should -Match '"contentType":"application/vnd.microsoft.card.hero"' + } + It 'supports Send-TeamsMessage in WhatIf mode with typed input' { - Import-Module "$PSScriptRoot\..\TeamsX.psd1" -Force + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force $message = New-TeamsMessage -Text 'Hello from TeamsX' $target = New-TeamsWebhookTarget -Uri 'https://example.test/webhook' { Send-TeamsMessage -Message $message -Target $target -WhatIf } | Should -Not -Throw } + + It 'supports Send-TeamsMessage in WhatIf mode with typed wrapper-card input' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' + ) + $thumbnailCard = New-TeamsThumbnailCard -Title 'Bender' -Images @( + New-TeamsCardImage -Url 'https://example.test/bender.png' + ) + $listCard = New-TeamsListCard -Title 'Card Title' -Items @( + New-CardListItem -Type ResultItem -Title 'Report' -SubTitle 'teams > new > design' + ) + $target = New-TeamsWebhookTarget -Uri 'https://example.test/webhook' + + { Send-TeamsMessage -HeroCard $heroCard -Target $target -WhatIf } | Should -Not -Throw + { Send-TeamsMessage -ThumbnailCard $thumbnailCard -Target $target -WhatIf } | Should -Not -Throw + { Send-TeamsMessage -ListCard $listCard -Target $target -WhatIf } | Should -Not -Throw + } + + It 'exposes the migrated Send-TeamsMessage cmdlet as the active public command' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + (Get-Command Send-TeamsMessage).CommandType | Should -Be 'Cmdlet' + (Get-Command Send-TeamsMessage).Source | Should -Be 'PSTeams' + } } diff --git a/Module/Tests/Import-Module.Tests.ps1 b/Module/Tests/Import-Module.Tests.ps1 index d05c317..f1a3723 100644 --- a/Module/Tests/Import-Module.Tests.ps1 +++ b/Module/Tests/Import-Module.Tests.ps1 @@ -1,34 +1,156 @@ -Describe 'TeamsX module' { +Describe 'PSTeams module migration shell' { BeforeEach { - Get-Module TeamsX, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue + Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue } - It 'exports cmdlets only' { - $module = Import-Module "$PSScriptRoot\..\TeamsX.psd1" -Force -PassThru + It 'exports legacy functions and migrated cmdlets together' { + $module = Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force -PassThru - $module.ExportedFunctions.Count | Should -Be 0 - $module.ExportedAliases.Count | Should -Be 0 + $module.ExportedFunctions.Keys | Should -BeNullOrEmpty + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveCard' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveAction' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveActionSet' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveCard' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveColumn' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveColumnSet' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveContainer' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveFact' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveFactSet' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveImage' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveImageSet' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveLineBreak' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveMedia' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveMediaSource' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveMention' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveRichTextBlock' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveTable' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-AdaptiveTextBlock' + $module.ExportedFunctions.Keys | Should -Not -Contain 'ConvertTo-TeamsFact' + $module.ExportedFunctions.Keys | Should -Not -Contain 'ConvertTo-TeamsSection' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsSection' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsFact' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsButton' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-CardList' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-CardListButton' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-CardListItem' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-HeroCard' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsActivityTitle' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsActivitySubtitle' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsActivityText' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsActivityImage' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsImage' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsBigImage' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsList' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-TeamsListItem' + $module.ExportedFunctions.Keys | Should -Not -Contain 'New-ThumbnailCard' + $module.ExportedFunctions.Keys | Should -Not -Contain 'Send-TeamsMessage' + $module.ExportedFunctions.Keys | Should -Not -Contain 'Send-TeamsMessageBody' + $module.ExportedAliases.Keys | Should -Contain 'TeamsMessage' + $module.ExportedAliases.Keys | Should -Contain 'TeamsSection' + $module.ExportedAliases.Keys | Should -Contain 'TeamsFact' + $module.ExportedAliases.Keys | Should -Contain 'TeamsButton' + $module.ExportedAliases.Keys | Should -Contain 'TeamsActivityTitle' + $module.ExportedAliases.Keys | Should -Contain 'TeamsActivitySubtitle' + $module.ExportedAliases.Keys | Should -Contain 'TeamsActivityText' + $module.ExportedAliases.Keys | Should -Contain 'TeamsActivityImage' + $module.ExportedAliases.Keys | Should -Contain 'TeamsImage' + $module.ExportedAliases.Keys | Should -Contain 'TeamsBigImage' + $module.ExportedAliases.Keys | Should -Contain 'New-HeroButton' + $module.ExportedAliases.Keys | Should -Contain 'New-HeroImage' + $module.ExportedAliases.Keys | Should -Contain 'New-AdaptiveImageGallery' + $module.ExportedAliases.Keys | Should -Contain 'New-ThumbnailButton' + $module.ExportedAliases.Keys | Should -Contain 'New-ThumbnailImage' + $module.ExportedAliases.Keys | Should -Contain 'TeamsMessageBody' + + (Get-Alias -Name 'New-HeroImage').Definition | Should -Be 'New-AdaptiveImage' + (Get-Alias -Name 'New-ThumbnailImage').Definition | Should -Be 'New-AdaptiveImage' + (Get-Command -Name 'Convert-Color' -Module PSTeams -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + + $module.ExportedCmdlets.Keys | Should -Contain 'ConvertTo-TeamsFact' $module.ExportedCmdlets.Keys | Should -Contain 'ConvertTo-TeamsJson' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveActionSet' + $module.ExportedCmdlets.Keys | Should -Contain 'ConvertTo-TeamsSection' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveAction' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveActionSet' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveCard' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveColumn' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveColumnSet' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveContainer' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveFact' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveFactSet' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveImage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveImageSet' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveLineBreak' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveMedia' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveMediaSource' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveMention' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveRichTextBlock' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveTable' + $module.ExportedCmdlets.Keys | Should -Contain 'New-AdaptiveTextBlock' + $module.ExportedCmdlets.Keys | Should -Contain 'New-CardList' + $module.ExportedCmdlets.Keys | Should -Contain 'New-CardListButton' + $module.ExportedCmdlets.Keys | Should -Contain 'New-CardListItem' + $module.ExportedCmdlets.Keys | Should -Contain 'New-HeroCard' $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveCard' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveColumn' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveColumnSet' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveContainer' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveFact' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveFactSet' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveImage' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveImageSet' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveMedia' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveMediaSource' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveMention' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveOpenUrlAction' $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveRichTextBlock' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveTextBlock' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveTextRun' - $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveToggleVisibilityAction' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveShowCardAction' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsAdaptiveSubmitAction' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsActivityTitle' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsActivitySubtitle' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsActivityText' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsActivityImage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsCardImage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsImage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsBigImage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsList' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsListItem' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsButton' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsFact' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsGraphTarget' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsHeroCard' $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsMessage' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsSection' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsListCard' + $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsThumbnailCard' $module.ExportedCmdlets.Keys | Should -Contain 'New-TeamsWebhookTarget' + $module.ExportedCmdlets.Keys | Should -Contain 'New-ThumbnailCard' $module.ExportedCmdlets.Keys | Should -Contain 'Send-TeamsMessage' + $module.ExportedCmdlets.Keys | Should -Contain 'Send-TeamsMessageBody' + } + + It 'preserves every legacy command name on main' { + $legacyScript = @' +Import-Module 'C:\Support\GitHub\PSTeams\PSTeams.psd1' -Force +Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | ConvertTo-Json +'@ + $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") + $currentScript = @' +Import-Module '{CURRENT_PATH}' -Force +Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | ConvertTo-Json +'@.Replace('{CURRENT_PATH}', $currentPath) + + $legacyNames = @(pwsh -NoProfile -Command $legacyScript | ConvertFrom-Json) + $currentNames = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) + + $missing = @($legacyNames | Where-Object { $_ -notin $currentNames } | Sort-Object) + $missing | Should -BeNullOrEmpty + } + + It 'preserves every legacy alias target on main' { + $legacyScript = @' +Import-Module 'C:\Support\GitHub\PSTeams\PSTeams.psd1' -Force +Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object | ConvertTo-Json +'@ + $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") + $currentScript = @' +Import-Module '{CURRENT_PATH}' -Force +Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object | ConvertTo-Json +'@.Replace('{CURRENT_PATH}', $currentPath) + + $legacyAliases = @(pwsh -NoProfile -Command $legacyScript | ConvertFrom-Json) + $currentAliases = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) + + $missing = @($legacyAliases | Where-Object { $_ -notin $currentAliases } | Sort-Object) + $missing | Should -BeNullOrEmpty } } diff --git a/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 b/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 new file mode 100644 index 0000000..30ca32d --- /dev/null +++ b/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 @@ -0,0 +1,214 @@ +Describe 'Legacy adaptive leaf migration cmdlets' { + BeforeEach { + Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + } + + It 'creates typed adaptive leaf elements from migrated cmdlets' { + $textBlock = New-AdaptiveTextBlock -Text '' -Color Attention -Wrap -Subtle -HorizontalAlignment Center -Separator -Spacing None -Id 'heading' + $image = New-AdaptiveImage -Url 'https://example.test/avatar.png' -AlternateText 'Avatar' -Size Small -Style person -HorizontalAlignment Right -HeightInPixels 40 -WidthInPixels 32 -Id 'avatar' -Hidden -BackgroundColor DodgerBlue -SelectActionUrl 'https://example.test/profile' -SelectActionTitle 'Open profile' + $mention = New-AdaptiveMention -Text 'Ops Team' -UserPrincipalName 'ops@example.test' -Name 'Ops Team' + $submitAction = New-AdaptiveAction -Title 'Approve' -Type Action.Submit + $openUrlAction = New-AdaptiveAction -Title 'Open build' -ActionUrl 'https://example.test/build' + $showCardAction = New-AdaptiveAction -Title 'Details' -Body { + New-AdaptiveTextBlock -Text 'Nested details' + } -Actions { + New-AdaptiveAction -Title 'Nested approve' -Type Action.Submit + } + $container = New-AdaptiveContainer -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Style Emphasis -MinimumHeight 120 -Bleed -VerticalContentAlignment center -Id 'panel' -Hidden -BackgroundUrl 'https://example.test/background.png' -BackgroundFillMode Cover -BackgroundHorizontalAlignment left -BackgroundVerticalAlignment top -SelectActionUrl 'https://example.test/panel' -SelectActionTitle 'Open panel' { + New-AdaptiveTextBlock -Text 'Container body' + } + $weightedColumn = New-AdaptiveColumn -WidthInWeight 2 -Spacing Small -Height Stretch -MinimumHeight 90 -HorizontalAlignment Right -VerticalContentAlignment Bottom -Style Attention -Hidden -Separator -SelectAction Action.ToggleVisibility -SelectActionId 'toggle-column' -SelectActionTitle 'Toggle column' -SelectActionTargetElement 'detailsBlock' { + New-AdaptiveTextBlock -Text 'Weighted column' + } + $pixelColumn = New-AdaptiveColumn -WidthInPixels 48 { + New-AdaptiveImage -Url 'https://example.test/status.png' -AlternateText 'Status' + } + $columnSet = New-AdaptiveColumnSet -Style Good -MinimumHeight 80 -Bleed -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch { + $weightedColumn + $pixelColumn + } + $actionSet = New-AdaptiveActionSet { + New-AdaptiveAction -Title 'View' -ActionUrl 'https://example.test/view' + New-AdaptiveAction -Title 'Approve' -Type Action.Submit + } + $mediaSource = New-AdaptiveMediaSource -Type 'video/mp4' -Url 'https://example.test/video.mp4' + $media = New-AdaptiveMedia -PosterUrl 'https://example.test/poster.png' -AlternateText 'Walkthrough' -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'demo' -Hidden { + New-AdaptiveMediaSource -Type 'video/mp4' -Url 'https://example.test/video.mp4' + } + $fact = New-AdaptiveFact -Title 'Status' -Value 'Failed' + $factSet = New-AdaptiveFactSet -Spacing Medium -Height Stretch -Separator { + New-AdaptiveFact -Title 'Status' -Value 'Failed' + New-AdaptiveFact -Title 'Build' -Value '42' + } + $richText = New-AdaptiveRichTextBlock -Text 'Build ', 'failed' -Color Default, Attention -Weight Default, Bolder -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'summary' -Hidden + $lineBreak = New-AdaptiveLineBreak + $imageSet = New-AdaptiveImageSet -Size Small -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'gallery' -Hidden { + New-AdaptiveImage -Url 'https://example.test/one.png' -AlternateText 'One' + New-AdaptiveImage -Url 'https://example.test/two.png' -AlternateText 'Two' + } + + $textBlock.GetType().Name | Should -Be 'TeamsAdaptiveTextBlock' + $textBlock.Text | Should -Be "$([char]0x200F)" + $textBlock.Wrap | Should -BeTrue + $textBlock.Subtle | Should -BeTrue + $image.GetType().Name | Should -Be 'TeamsAdaptiveImage' + $image.BackgroundColor | Should -Be '#1E90FF' + $image.SelectAction.GetType().Name | Should -Be 'TeamsAdaptiveOpenUrlAction' + $mention.GetType().Name | Should -Be 'TeamsAdaptiveMention' + $mention.Text | Should -Be 'Ops Team' + $submitAction.GetType().Name | Should -Be 'TeamsAdaptiveSubmitAction' + $openUrlAction.GetType().Name | Should -Be 'TeamsAdaptiveOpenUrlAction' + $showCardAction.GetType().Name | Should -Be 'TeamsAdaptiveShowCardAction' + $showCardAction.Card.type | Should -Be 'AdaptiveCard' + $container.GetType().Name | Should -Be 'TeamsAdaptiveContainer' + $container.MinimumHeight | Should -Be '120px' + $container.Bleed | Should -BeTrue + $container.IsVisible | Should -BeFalse + $container.SelectAction.GetType().Name | Should -Be 'TeamsAdaptiveOpenUrlAction' + $columnSet.GetType().Name | Should -Be 'TeamsAdaptiveColumnSet' + $columnSet.Columns.Count | Should -Be 2 + $columnSet.Bleed | Should -BeTrue + $weightedColumn.GetType().Name | Should -Be 'TeamsAdaptiveColumn' + $weightedColumn.Width | Should -Be '2' + $weightedColumn.SelectAction.GetType().Name | Should -Be 'TeamsAdaptiveToggleVisibilityAction' + $pixelColumn.Width | Should -Be '48px' + $actionSet.GetType().Name | Should -Be 'TeamsAdaptiveActionSet' + $actionSet.Actions.Count | Should -Be 2 + $mediaSource.GetType().Name | Should -Be 'TeamsAdaptiveMediaSource' + $mediaSource.MimeType | Should -Be 'video/mp4' + $media.GetType().Name | Should -Be 'TeamsAdaptiveMedia' + $media.Separator | Should -BeTrue + $media.IsVisible | Should -BeFalse + $media.Sources.Count | Should -Be 1 + $fact.GetType().Name | Should -Be 'TeamsAdaptiveFact' + $fact.Title | Should -Be 'Status' + $factSet.GetType().Name | Should -Be 'TeamsAdaptiveFactSet' + $factSet.Separator | Should -BeTrue + $factSet.Facts.Count | Should -Be 2 + $richText.GetType().Name | Should -Be 'TeamsAdaptiveRichTextBlock' + $richText.Separator | Should -BeTrue + $richText.IsVisible | Should -BeFalse + $richText.Inlines.Count | Should -Be 2 + $lineBreak.GetType().Name | Should -Be 'TeamsAdaptiveTextBlock' + $lineBreak.Text | Should -Be "`n" + $imageSet.GetType().Name | Should -Be 'TeamsAdaptiveImageSet' + $imageSet.Separator | Should -BeTrue + $imageSet.IsVisible | Should -BeFalse + $imageSet.Images.Count | Should -Be 2 + } + + It 'renders New-AdaptiveCard JSON when body contains migrated adaptive cmdlets' { + $json = New-AdaptiveCard -ReturnJson { + New-AdaptiveTextBlock -Text '' -Color Attention -Wrap -Subtle -HorizontalAlignment Center -Separator -Spacing None -Id 'heading' + New-AdaptiveContainer -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Style Emphasis -MinimumHeight 120 -Bleed -VerticalContentAlignment center -Id 'panel' -Hidden -BackgroundUrl 'https://example.test/background.png' -BackgroundFillMode Cover -BackgroundHorizontalAlignment left -BackgroundVerticalAlignment top -SelectActionUrl 'https://example.test/panel' -SelectActionTitle 'Open panel' { + New-AdaptiveTextBlock -Text 'Container body' + New-AdaptiveColumnSet -Style Good -MinimumHeight 80 -Bleed -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch { + New-AdaptiveColumn -WidthInWeight 2 -Spacing Small -Height Stretch -MinimumHeight 90 -HorizontalAlignment Right -VerticalContentAlignment Bottom -Style Attention -Hidden -Separator -SelectAction Action.ToggleVisibility -SelectActionId 'toggle-column' -SelectActionTitle 'Toggle column' -SelectActionTargetElement 'detailsBlock' { + New-AdaptiveTextBlock -Text 'Weighted column' + } + New-AdaptiveColumn -Width Auto { + New-AdaptiveImage -Url 'https://example.test/status.png' -AlternateText 'Status' + } + } + } + New-AdaptiveImage -Url 'https://example.test/avatar.png' -AlternateText 'Avatar' -Size Small -Style person -HorizontalAlignment Right -HeightInPixels 40 -WidthInPixels 32 -Id 'avatar' -Hidden -BackgroundColor DodgerBlue -SelectActionUrl 'https://example.test/profile' -SelectActionTitle 'Open profile' + New-AdaptiveFactSet -Spacing Medium -Height Stretch -Separator { + New-AdaptiveFact -Title 'Status' -Value 'Failed' + New-AdaptiveFact -Title 'Build' -Value '42' + } + New-AdaptiveRichTextBlock -Text 'Build ', 'failed' -Color Default, Attention -Weight Default, Bolder -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'summary' -Hidden + New-AdaptiveLineBreak + New-AdaptiveImageGallery -Size Small -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'gallery' -Hidden { + New-AdaptiveImage -Url 'https://example.test/one.png' -AlternateText 'One' + New-AdaptiveImage -Url 'https://example.test/two.png' -AlternateText 'Two' + } + New-AdaptiveActionSet { + New-AdaptiveAction -Title 'View in body' -ActionUrl 'https://example.test/body' + New-AdaptiveAction -Title 'Submit in body' -Type Action.Submit + } + New-AdaptiveMedia -PosterUrl 'https://example.test/poster.png' -AlternateText 'Walkthrough' -Spacing Medium -Separator -HorizontalAlignment Center -Height Stretch -Id 'demo' -Hidden { + New-AdaptiveMediaSource -Type 'video/mp4' -Url 'https://example.test/video.mp4' + } + New-AdaptiveMention -Text 'Ops Team' -UserPrincipalName 'ops@example.test' -Name 'Ops Team' + } -Action { + New-AdaptiveAction -Title 'Approve' -Type Action.Submit + New-AdaptiveAction -Title 'Open build' -ActionUrl 'https://example.test/build' + New-AdaptiveAction -Title 'Show details' -Body { + New-AdaptiveTextBlock -Text 'Nested details' + } -Actions { + New-AdaptiveAction -Title 'Nested approve' -Type Action.Submit + } + } + + $json | Should -Match '"type"\s*:\s*"ActionSet"' + $json | Should -Match '"type"\s*:\s*"TextBlock"' + $json | Should -Match '"wrap"\s*:\s*true' + $json | Should -Match '"isSubtle"\s*:\s*true' + $json | Should -Match '"type"\s*:\s*"Container"' + $json | Should -Match '"minHeight"\s*:\s*"120px"' + $json | Should -Match '"backgroundImage"\s*:\s*\{' + $json | Should -Match '"type"\s*:\s*"ColumnSet"' + $json | Should -Match '"type"\s*:\s*"Column"' + $json | Should -Match '"width"\s*:\s*"2"' + $json | Should -Match '"targetElements"\s*:\s*\[\s*"detailsBlock"\s*\]' + $json | Should -Match '"type"\s*:\s*"FactSet"' + $json | Should -Match '"separator"\s*:\s*true' + $json | Should -Match '"title"\s*:\s*"Status"' + $json | Should -Match '"value"\s*:\s*"Failed"' + $json | Should -Match '"type"\s*:\s*"Image"' + $json | Should -Match '"backgroundColor"\s*:\s*"#1E90FF"' + $json | Should -Match '"type"\s*:\s*"RichTextBlock"' + $json | Should -Match '"id"\s*:\s*"summary"' + $json | Should -Match '"type"\s*:\s*"ImageSet"' + $json | Should -Match '"imageSize"\s*:\s*"Small"' + $json | Should -Match '"type"\s*:\s*"Media"' + $json | Should -Match '"mimeType"\s*:\s*"video/mp4"' + $json | Should -Match '"type"\s*:\s*"Action.Submit"' + $json | Should -Match '"type"\s*:\s*"Action.ShowCard"' + $json | Should -Match '"card"\s*:\s*\{' + $json | Should -Match '"type"\s*:\s*"Action.OpenUrl"' + $json | Should -Match '"type"\s*:\s*"mention"' + $json | Should -Match '"entities"\s*:\s*\[' + } + + It 'returns adaptive card JSON when Uri and ReturnJson are used together' { + $json = New-AdaptiveCard -Uri 'https://example.test/webhook' -ReturnJson -WhatIf -FallBackText 'Fallback text' -MinimumHeight 140 -Speak 'Build failed' -Language 'en' -VerticalContentAlignment center -BackgroundUrl 'https://example.test/background.png' -BackgroundFillMode Cover -BackgroundHorizontalAlignment left -BackgroundVerticalAlignment top -SelectActionUrl 'https://example.test/card' -SelectActionTitle 'Open card' -AllowImageExpand -FullWidth { + New-AdaptiveTextBlock -Text 'Build failed' + New-AdaptiveMention -Text 'Ops Team' -UserPrincipalName 'ops@example.test' -Name 'Ops Team' + } -Action { + New-AdaptiveAction -Title 'Open build' -ActionUrl 'https://example.test/build' + } + + $json | Should -Match '"type"\s*:\s*"message"' + $json | Should -Match '"fallbackText"\s*:\s*"Fallback text"' + $json | Should -Match '"minHeight"\s*:\s*"140px"' + $json | Should -Match '"speak"\s*:\s*"Build failed"' + $json | Should -Match '"lang"\s*:\s*"en"' + $json | Should -Match '"allowExpand"\s*:\s*true' + $json | Should -Match '"width"\s*:\s*"Full"' + $json | Should -Match '"selectAction"\s*:\s*\{' + $json | Should -Match '"url"\s*:\s*"https://example.test/card"' + $json | Should -Match '"entities"\s*:\s*\[' + } + + It 'creates adaptive table rows from objects and dictionaries' { + $objectTable = @(New-AdaptiveTable -DataTable @( + [pscustomobject]@{ Name = 'Server01'; Status = 'Failed' } + [pscustomobject]@{ Name = 'Server02'; Status = 'Passed' } + )) + $dictionaryTable = @(New-AdaptiveTable -DataTable @( + [ordered]@{ Name = 'Server01'; Status = 'Failed' } + [ordered]@{ Name = 'Server02'; Status = 'Passed' } + ) -DictionaryAsCustomObject) + + $objectTable.Count | Should -Be 3 + $objectTable[0].GetType().Name | Should -Be 'TeamsAdaptiveColumnSet' + $objectTable[0].Columns.Count | Should -Be 2 + $objectTable[1].Columns[0].Items[0].Text | Should -Be 'Server01' + $objectTable[1].Columns[1].Items[0].Text | Should -Be 'Failed' + $dictionaryTable.Count | Should -Be 3 + $dictionaryTable[0].Columns[0].Items[0].Text | Should -Be 'Name' + $dictionaryTable[1].Columns[1].Items[0].Text | Should -Be 'Failed' + } +} diff --git a/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 new file mode 100644 index 0000000..d34f32e --- /dev/null +++ b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 @@ -0,0 +1,114 @@ +Describe 'Legacy connector-card migration cmdlets' { + BeforeEach { + Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + } + + It 'creates typed connector-card building blocks from migrated cmdlets' { + $fact = New-TeamsFact -Name 'Status' -Value 'Failed' + $button = New-TeamsButton -Name 'Open build' -Link 'https://example.test/build/42' + $section = New-TeamsSection -Title 'Build summary' -ActivityText 'Pipeline failed' -ActivityDetails $fact -Buttons $button + + $fact.GetType().Name | Should -Be 'TeamsMessageFact' + $button.GetType().Name | Should -Be 'TeamsMessageButton' + $section.GetType().Name | Should -Be 'TeamsMessageSection' + $section.Facts.Count | Should -Be 1 + $section.Buttons.Count | Should -Be 1 + } + + It 'supports section helper cmdlets inside the legacy composition scriptblock' { + $section = New-TeamsSection { + New-TeamsActivityTitle -Title 'Build title' + New-TeamsActivitySubtitle -Subtitle 'Build subtitle' + New-TeamsActivityText -Text 'Build text' + New-TeamsActivityImage -Link 'https://example.test/activity.png' + New-TeamsImage -Link 'https://example.test/image.png' + New-TeamsBigImage -Link 'https://example.test/hero.png' -AlternativeText 'Hero' + New-TeamsFact -Name 'Status' -Value 'Failed' + } + + $section.ActivityTitle | Should -Be 'Build title' + $section.ActivitySubtitle | Should -Be 'Build subtitle' + $section.ActivityText | Should -Be 'Build text' + $section.ActivityImage | Should -Be 'https://example.test/activity.png' + $section.Images.Count | Should -Be 1 + $section.HeroImages.Count | Should -Be 1 + $section.Facts.Count | Should -Be 1 + } + + It 'creates legacy list facts from migrated cmdlets' { + $fact = New-TeamsList -Name 'Checklist' { + New-TeamsListItem -Text 'Top level' -Level 0 + New-TeamsListItem -Text 'Nested ordered' -Level 1 -Numbered + } + + $fact.GetType().Name | Should -Be 'TeamsMessageFact' + $fact.Name | Should -Be 'Checklist' + $fact.Value | Should -Be "- Top level`r`t1. Nested ordered" + } + + It 'converts objects into facts and sections using migrated cmdlets' { + $facts = [pscustomobject]@{ + BuildStatus = 'Failed' + BuildId = 42 + } | ConvertTo-TeamsFact + + $sections = @( + [pscustomobject]@{ + Name = 'Pipeline' + Status = 'Failed' + } + ) | ConvertTo-TeamsSection -SectionTitleProperty Name + + $facts.Count | Should -Be 2 + $facts[0].GetType().Name | Should -Be 'TeamsMessageFact' + $facts.Name | Should -Contain 'BuildStatus' + $sections.Count | Should -Be 1 + $sections[0].GetType().Name | Should -Be 'TeamsMessageSection' + $sections[0].ActivityTitle | Should -Be 'Name Pipeline' + $sections[0].Facts.Count | Should -Be 2 + } + + It 'renders legacy Send-TeamsMessage payloads without sending when using WhatIf' { + $body = Send-TeamsMessage -Uri 'https://example.test/webhook' -MessageTitle 'Build failed' -MessageText 'Pipeline 42' -Color DodgerBlue -Sections @( + New-TeamsSection -Title 'Section' -ActivityDetails @( + New-TeamsFact -Name 'Status' -Value 'Failed' + ) -Buttons @( + New-TeamsButton -Name 'Open build' -Link 'https://example.test/build/42' -Type OpenUri + ) + ) -Suppress:$false -WhatIf + + $body | Should -Match '"themeColor":"#1E90FF"' + $body | Should -Match '"title":"Build failed"' + $body | Should -Match '"name":"Status"' + $body | Should -Match '"@type":"OpenURI"' + } + + It 'renders helper-based section content in the legacy Send-TeamsMessage scriptblock path' { + $body = Send-TeamsMessage -Uri 'https://example.test/webhook' -MessageTitle 'Build failed' -Suppress:$false -WhatIf { + New-TeamsSection { + New-TeamsActivityTitle -Title 'Build title' + New-TeamsActivitySubtitle -Subtitle 'Build subtitle' + New-TeamsActivityText -Text 'Build text' + New-TeamsActivityImage -Link 'https://example.test/activity.png' + New-TeamsImage -Link 'https://example.test/image.png' + New-TeamsBigImage -Link 'https://example.test/hero.png' -AlternativeText 'Hero' + } + } + + $body | Should -Match '"activityTitle":"Build title"' + $body | Should -Match '"activitySubtitle":"Build subtitle"' + $body | Should -Match '"activityText":"Build text"' + $body | Should -Match '"activityImage":"https://example.test/activity.png"' + $body | Should -Match '"images":\[' + $body | Should -Match '!\[Hero\]\(https://example.test/hero.png\)' + } + + It 'wraps raw attachment bodies without sending when using WhatIf' { + $body = Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Body '{"contentType":"application/vnd.microsoft.card.hero"}' -Wrap -Supress:$false -WhatIf + + $body | Should -Match '"type":"message"' + $body | Should -Match '"attachments":\[' + $body | Should -Match '"contentType":"application/vnd.microsoft.card.hero"' + } +} diff --git a/Module/Tests/WrapperCard.Compose.Tests.ps1 b/Module/Tests/WrapperCard.Compose.Tests.ps1 new file mode 100644 index 0000000..bb04387 --- /dev/null +++ b/Module/Tests/WrapperCard.Compose.Tests.ps1 @@ -0,0 +1,85 @@ +Describe 'Wrapper-card migration cmdlets' { + BeforeEach { + Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + } + + It 'renders HeroCard payloads from migrated cmdlets' { + $body = New-HeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text 'Monorail text' { + New-HeroImage -Url 'https://example.test/monorail.jpg' + New-HeroButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' + } + + $body | Should -Match '"contentType":"application/vnd.microsoft.card.hero"' + $body | Should -Match '"title":"Seattle Center Monorail"' + $body | Should -Match '"subTitle":"Seattle Center Monorail"' + $body | Should -Match '"url":"https://example.test/monorail.jpg"' + $body | Should -Match '"type":"openUrl"' + } + + It 'accepts legacy adaptive-image aliases inside wrapper cards' { + $body = New-ThumbnailCard -Title 'Bender' -SubTitle 'robot' -Text 'Futurama' { + New-ThumbnailImage -Url 'https://example.test/bender.png' -AltText 'Bender' + New-ThumbnailButton -Type ImBack -Title 'Thumbs Up' -Value 'I like it' + } + + $body | Should -Match '"contentType":"application/vnd.microsoft.card.thumbnail"' + $body | Should -Match '"url":"https://example.test/bender.png"' + $body | Should -Match '"alt":"Bender"' + } + + It 'supports HeroCard sending in WhatIf mode' { + $result = New-HeroCard -Title 'Seattle Center Monorail' -Uri 'https://example.test/webhook' -WhatIf { + New-HeroImage -Url 'https://example.test/monorail.jpg' + New-HeroButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' + } + + $result | Should -BeNullOrEmpty + } + + It 'renders ThumbnailCard payloads from migrated cmdlets' { + $body = New-ThumbnailCard -Title 'Bender' -SubTitle 'robot' -Text 'Futurama' { + New-ThumbnailImage -Url 'https://example.test/bender.png' -AltText 'Bender' + New-ThumbnailButton -Type ImBack -Title 'Thumbs Up' -Value 'I like it' + } + + $body | Should -Match '"contentType":"application/vnd.microsoft.card.thumbnail"' + $body | Should -Match '"title":"Bender"' + $body | Should -Match '"alt":"Bender"' + $body | Should -Match '"type":"imBack"' + $body | Should -Match '"value":"I like it"' + } + + It 'supports ThumbnailCard sending in WhatIf mode' { + $result = New-ThumbnailCard -Title 'Bender' -Uri 'https://example.test/webhook' -WhatIf { + New-ThumbnailImage -Url 'https://example.test/bender.png' -AltText 'Bender' + New-ThumbnailButton -Type ImBack -Title 'Thumbs Up' -Value 'I like it' + } + + $result | Should -BeNullOrEmpty + } + + It 'renders ListCard payloads from migrated cmdlets' { + $body = New-CardList -Title 'Card Title' { + New-CardListItem -Type File -Title 'Report' -SubTitle 'teams > new > design' -TapType OpenUrl -TapValue 'https://contoso.example/report.xlsx' -TapAction editOnline + New-CardListItem -Type Person -Title 'John Doe' -SubTitle 'Manager' -TapType ImBack -TapValue 'JohnDoe@contoso.com' -TapAction whois + New-CardListButton -Type OpenUrl -Title 'Show' -Value 'https://evotec.xyz' + } + + $body | Should -Match '"contentType":"application/vnd.microsoft.teams.card.list"' + $body | Should -Match '"type":"file"' + $body | Should -Match '"value":"editOnline https://contoso.example/report.xlsx"' + $body | Should -Match '"type":"person"' + $body | Should -Match '"value":"whois JohnDoe@contoso.com"' + $body | Should -Match '"title":"Show"' + } + + It 'supports ListCard sending in WhatIf mode' { + $result = New-CardList -Title 'Card Title' -Uri 'https://example.test/webhook' -WhatIf { + New-CardListItem -Type File -Title 'Report' -SubTitle 'teams > new > design' -TapType OpenUrl -TapValue 'https://contoso.example/report.xlsx' -TapAction editOnline + New-CardListButton -Type OpenUrl -Title 'Show' -Value 'https://evotec.xyz' + } + + $result | Should -BeNullOrEmpty + } +} diff --git a/PLAN.md b/PLAN.md index 2f8838c..574d057 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,15 +2,15 @@ ## Main Branch Direction -This branch is the clean-slate `TeamsX` line. +This branch keeps `PSTeams` as the product surface while migrating implementation to `TeamsX`. The public shape on `main` is: - `TeamsX` as the reusable .NET library - `TeamsX.PowerShell` as thin binary cmdlets -- `Module` as the shipping PowerShell module layout +- `Module\PSTeams` as the shipping PowerShell module layout -The old `PSTeams` function surface is not being carried forward on `main`. +The old `PSTeams` function surface is the migration contract on `main`: commands remain script-based until equivalent C# cmdlets exist. ## Current Goals @@ -20,4 +20,4 @@ The old `PSTeams` function surface is not being carried forward on `main`. 4. use the standard `Build\Build-Project.ps1` flow for the library/repository build 5. use `Module\Build\Build-Module.ps1` for the PowerShell module packaging path 6. introduce Graph channel/chat senders as the next delivery backends -7. keep the PowerShell surface cmdlet-only unless a future need clearly requires otherwise +7. keep the public module as a hybrid shell until each legacy command is fully converted and tested diff --git a/PSTeams.psd1 b/PSTeams.psd1 deleted file mode 100644 index 5c32ae2..0000000 --- a/PSTeams.psd1 +++ /dev/null @@ -1,27 +0,0 @@ -@{ - AliasesToExport = @('New-HeroImage', 'New-ThumbnailImage', 'New-AdaptiveImageGallery', 'New-HeroButton', 'New-ThumbnailButton', 'ActivityImageLink', 'TeamsActivityImageLink', 'New-TeamsActivityImageLink', 'ActivityImage', 'TeamsActivityImage', 'ActivitySubtitle', 'TeamsActivitySubtitle', 'ActivityText', 'TeamsActivityText', 'ActivityTitle', 'TeamsActivityTitle', 'TeamsBigImage', 'TeamsButton', 'TeamsFact', 'TeamsImage', 'TeamsList', 'TeamsListItem', 'TeamsSection', 'TeamsMessage', 'TeamsMessageBody') - Author = 'Przemyslaw Klys' - CmdletsToExport = @() - CompanyName = 'Evotec' - CompatiblePSEditions = @('Desktop', 'Core') - Copyright = '(c) 2011 - 2023 Przemyslaw Klys @ Evotec. All rights reserved.' - Description = 'PSTeams is a PowerShell Module working on Windows / Linux and Mac. It allows sending notifications to Microsoft Teams via WebHook Notifications. It''s pretty flexible and provides a bunch of options. Initially, it only supported one sort of Team Cards but since version 2.X.X it supports Adaptive Cards, Hero Cards, List Cards, and Thumbnail Cards. All those new cards have their own cmdlets and the old version of creating Teams Cards stays as-is for compatibility reasons.' - FunctionsToExport = @('ConvertTo-TeamsFact', 'ConvertTo-TeamsSection', 'New-AdaptiveAction', 'New-AdaptiveActionSet', 'New-AdaptiveCard', 'New-AdaptiveColumn', 'New-AdaptiveColumnSet', 'New-AdaptiveContainer', 'New-AdaptiveFact', 'New-AdaptiveFactSet', 'New-AdaptiveImage', 'New-AdaptiveImageSet', 'New-AdaptiveLineBreak', 'New-AdaptiveMedia', 'New-AdaptiveMediaSource', 'New-AdaptiveMention', 'New-AdaptiveRichTextBlock', 'New-AdaptiveTable', 'New-AdaptiveTextBlock', 'New-CardList', 'New-CardListButton', 'New-CardListItem', 'New-HeroCard', 'New-TeamsActivityImage', 'New-TeamsActivitySubtitle', 'New-TeamsActivityText', 'New-TeamsActivityTitle', 'New-TeamsBigImage', 'New-TeamsButton', 'New-TeamsFact', 'New-TeamsImage', 'New-TeamsList', 'New-TeamsListItem', 'New-TeamsSection', 'New-ThumbnailCard', 'Send-TeamsMessage', 'Send-TeamsMessageBody') - GUID = 'a46c3b0b-5687-4d62-89c5-753ae01e0926' - ModuleVersion = '2.4.0' - PowerShellVersion = '5.1' - PrivateData = @{ - PSData = @{ - Tags = @('Teams', 'Microsoft', 'MSTeams', 'Notifications', 'Webhook', 'Windows', 'macOS', 'Linux') - IconUri = 'https://statics.teams.microsoft.com/evergreen-assets/apps/teamscmdlets_largeimage.png' - ProjectUri = 'https://github.com/EvotecIT/PSTeams' - ExternalModuleDependencies = @('Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Management') - } - } - RequiredModules = @(@{ - ModuleName = 'PSSharedGoods' - ModuleVersion = '0.0.264' - Guid = 'ee272aa8-baaa-4edf-9f45-b6d6f7d844fe' - }, 'Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Management') - RootModule = 'PSTeams.psm1' -} \ No newline at end of file diff --git a/PSTeams.psm1 b/PSTeams.psm1 deleted file mode 100644 index faa8ef8..0000000 --- a/PSTeams.psm1 +++ /dev/null @@ -1,16 +0,0 @@ -#Get public and private function definition files. -$Public = @( Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -ErrorAction SilentlyContinue ) -$Private = @( Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -ErrorAction SilentlyContinue ) -$Enums = @( Get-ChildItem -Path $PSScriptRoot\Enums\*.ps1 -ErrorAction SilentlyContinue ) - -#Dot source the files -Foreach ($import in @($Public + $Private + $Enums)) { - Try { - . $import.fullname - } Catch { - Write-Error -Message "Failed to import function $($import.fullname): $_" - - } -} - -Export-ModuleMember -Function * -Alias * \ No newline at end of file diff --git a/Private/Add-TeamsBody.ps1 b/Private/Add-TeamsBody.ps1 deleted file mode 100644 index 13cc078..0000000 --- a/Private/Add-TeamsBody.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -function Add-TeamsBody { - [CmdletBinding()] - param ( - [string] $MessageTitle, - [string] $ThemeColor, - [string] $MessageText, - [string] $MessageSummary, - [System.Collections.IDictionary[]] $Sections, - [switch] $HideOriginalBody - ) - - $Body = [ordered] @{ - sections = $Sections - } - if ($ThemeColor) { - $body.themeColor = $ThemeColor - } - if ($MessageTitle) { - $Body.title = $MessageTitle - } - if ($HideOriginalBody.IsPresent) { - $Body.hideOriginalBody = $HideOriginalBody.IsPresent - } - if ($MessageSummary -ne '') { - $Body.summary = $MessageSummary - } else { - if ($MessageTitle -ne '') { - $Body.summary = $MessageTitle - } elseif ($MessageText -ne '') { - $Body.summary = $MessageText - } - } - if ($MessageText -ne '') { - $Body.text = $MessageText - } - return $Body | ConvertTo-Json -Depth 6 -} \ No newline at end of file diff --git a/Private/Convert-Color.ps1 b/Private/Convert-Color.ps1 deleted file mode 100644 index 4e156f7..0000000 --- a/Private/Convert-Color.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -function Convert-Color { - <# - .Synopsis - This color converter gives you the hexadecimal values of your RGB colors and vice versa (RGB to HEX) - .Description - This color converter gives you the hexadecimal values of your RGB colors and vice versa (RGB to HEX). Use it to convert your colors and prepare your graphics and HTML web pages. - .Parameter RBG - Enter the Red Green Blue value comma separated. Red: 51 Green: 51 Blue: 204 for example needs to be entered as 51,51,204 - .Parameter HEX - Enter the Hex value to be converted. Do not use the '#' symbol. (Ex: 3333CC converts to Red: 51 Green: 51 Blue: 204) - .Example - .\convert-color -hex FFFFFF - Converts hex value FFFFFF to RGB - - .Example - .\convert-color -RGB 123,200,255 - Converts Red = 123 Green = 200 Blue = 255 to Hex value - - #> - [CmdletBinding()] - param( - [Parameter(ParameterSetName = "RGB", Position = 0)] - [ValidateScript( { $_ -match '^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$' })] - $RGB, - [Parameter(ParameterSetName = "HEX", Position = 0)] - [ValidateScript( { $_ -match '[A-Fa-f0-9]{6}' })] - [string] - $HEX - ) - switch ($PsCmdlet.ParameterSetName) { - "RGB" { - if ($null -eq $RGB[2]) { - Write-Error "Value missing. Please enter all three values seperated by comma." - } - $red = [convert]::Tostring($RGB[0], 16) - $green = [convert]::Tostring($RGB[1], 16) - $blue = [convert]::Tostring($RGB[2], 16) - if ($red.Length -eq 1) { - $red = '0' + $red - } - if ($green.Length -eq 1) { - $green = '0' + $green - } - if ($blue.Length -eq 1) { - $blue = '0' + $blue - } - Write-Output $red$green$blue - } - "HEX" { - $red = $HEX.Remove(2, 4) - $Green = $HEX.Remove(4, 2) - $Green = $Green.remove(0, 2) - $Blue = $hex.Remove(0, 4) - $Red = [convert]::ToInt32($red, 16) - $Green = [convert]::ToInt32($green, 16) - $Blue = [convert]::ToInt32($blue, 16) - Write-Output $red, $Green, $blue - } - } -} \ No newline at end of file diff --git a/Private/ConvertFrom-Color.ps1 b/Private/ConvertFrom-Color.ps1 deleted file mode 100644 index 32ae0ce..0000000 --- a/Private/ConvertFrom-Color.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -function ConvertFrom-Color { - [alias('Convert-FromColor')] - [CmdletBinding()] - param ( - [ValidateScript( { - if ($($_ -in $Script:RGBColors.Keys -or $_ -match "^#([A-Fa-f0-9]{6})$" -or $_ -eq "") -eq $false) { - throw "The Input value is not a valid colorname nor an valid color hex code." - } else { $true } - })] - [alias('Colors')][string[]] $Color, - [switch] $AsDecimal - ) - $Colors = foreach ($C in $Color) { - $Value = $Script:RGBColors."$C" - if ($C -match "^#([A-Fa-f0-9]{6})$") { - return $C - } - if ($null -eq $Value) { - return - } - $HexValue = Convert-Color -RGB $Value - Write-Verbose "Convert-FromColor - Color Name: $C Value: $Value HexValue: $HexValue" - if ($AsDecimal) { - [Convert]::ToInt64($HexValue, 16) - } else { - "#$($HexValue)" - } - } - $Colors -} -Register-ArgumentCompleter -CommandName ConvertFrom-Color -ParameterName Color -ScriptBlock { $Script:RGBColors.Keys } \ No newline at end of file diff --git a/Private/Get-Image.ps1 b/Private/Get-Image.ps1 deleted file mode 100644 index 66e5ff8..0000000 --- a/Private/Get-Image.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -function Get-Image { - [CmdletBinding()] - param( - [string] $PathToImages, - [string] $FileName, - [string] $FileExtension - ) - Write-Verbose "Get-Image - PathToImages $PathToImages FileName $FileName FileExtension $FileExtension" - $ImagePath = [IO.Path]::Combine( $PathToImages, "$($FileName)$FileExtension") - Write-Verbose "Get-Image - ImagePath $ImagePath" - if (Test-Path $ImagePath) { - if ($PSEdition -eq 'Core') { - $Image = [convert]::ToBase64String((Get-Content $ImagePath -AsByteStream)) - } else { - $Image = [convert]::ToBase64String((Get-Content $ImagePath -Encoding byte)) - } - Write-Verbose "Get-Image - Image Type: $($Image.GetType())" - return "data:image/png;base64,$Image" - } - return '' -} \ No newline at end of file diff --git a/Private/Repair-Text.ps1 b/Private/Repair-Text.ps1 deleted file mode 100644 index 4b45fd2..0000000 --- a/Private/Repair-Text.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -function Repair-Text { - [CmdletBinding()] - param( - [string] $Text - ) - if ($Text -ne $null) { - $Text = $Text.ToString().Replace('"', '\"').Replace('\', '\\').Replace("`n", '\n\n').Replace("`r", '').Replace("`t", '\t') - $Text = [System.Text.RegularExpressions.Regex]::Unescape($($Text)) - } - if ($Text -eq '') { $Text = ' ' } - return $Text -} \ No newline at end of file diff --git a/Private/Script.RGBColors.ps1 b/Private/Script.RGBColors.ps1 deleted file mode 100644 index 03c7ac8..0000000 --- a/Private/Script.RGBColors.ps1 +++ /dev/null @@ -1,752 +0,0 @@ -$Script:RGBColors = [ordered] @{ - None = $null - AirForceBlue = 93, 138, 168 - Akaroa = 195, 176, 145 - AlbescentWhite = 227, 218, 201 - AliceBlue = 240, 248, 255 - Alizarin = 227, 38, 54 - Allports = 18, 97, 128 - Almond = 239, 222, 205 - AlmondFrost = 159, 129, 112 - Amaranth = 229, 43, 80 - Amazon = 59, 122, 87 - Amber = 255, 191, 0 - Amethyst = 153, 102, 204 - AmethystSmoke = 156, 138, 164 - AntiqueWhite = 250, 235, 215 - Apple = 102, 180, 71 - AppleBlossom = 176, 92, 82 - Apricot = 251, 206, 177 - Aqua = 0, 255, 255 - Aquamarine = 127, 255, 212 - Armygreen = 75, 83, 32 - Arsenic = 59, 68, 75 - Astral = 54, 117, 136 - Atlantis = 164, 198, 57 - Atomic = 65, 74, 76 - AtomicTangerine = 255, 153, 102 - Axolotl = 99, 119, 91 - Azure = 240, 255, 255 - Bahia = 176, 191, 26 - BakersChocolate = 93, 58, 26 - BaliHai = 124, 152, 171 - BananaMania = 250, 231, 181 - BattleshipGrey = 85, 93, 80 - BayOfMany = 35, 48, 103 - Beige = 245, 245, 220 - Bermuda = 136, 216, 192 - Bilbao = 42, 128, 0 - BilobaFlower = 181, 126, 220 - Bismark = 83, 104, 114 - Bisque = 255, 228, 196 - Bistre = 61, 43, 31 - Bittersweet = 254, 111, 94 - Black = 0, 0, 0 - BlackPearl = 31, 38, 42 - BlackRose = 85, 31, 47 - BlackRussian = 23, 24, 43 - BlanchedAlmond = 255, 235, 205 - BlizzardBlue = 172, 229, 238 - Blue = 0, 0, 255 - BlueDiamond = 77, 26, 127 - BlueMarguerite = 115, 102, 189 - BlueSmoke = 115, 130, 118 - BlueViolet = 138, 43, 226 - Blush = 169, 92, 104 - BokaraGrey = 22, 17, 13 - Bole = 121, 68, 59 - BondiBlue = 0, 147, 175 - Bordeaux = 88, 17, 26 - Bossanova = 86, 60, 92 - Boulder = 114, 116, 114 - Bouquet = 183, 132, 167 - Bourbon = 170, 108, 57 - Brass = 181, 166, 66 - BrickRed = 199, 44, 72 - BrightGreen = 102, 255, 0 - BrightRed = 146, 43, 62 - BrightTurquoise = 8, 232, 222 - BrilliantRose = 243, 100, 162 - BrinkPink = 250, 110, 121 - BritishRacingGreen = 0, 66, 37 - Bronze = 205, 127, 50 - Brown = 165, 42, 42 - BrownPod = 57, 24, 2 - BuddhaGold = 202, 169, 6 - Buff = 240, 220, 130 - Burgundy = 128, 0, 32 - BurlyWood = 222, 184, 135 - BurntOrange = 255, 117, 56 - BurntSienna = 233, 116, 81 - BurntUmber = 138, 51, 36 - ButteredRum = 156, 124, 56 - CadetBlue = 95, 158, 160 - California = 224, 141, 60 - CamouflageGreen = 120, 134, 107 - Canary = 255, 255, 153 - CanCan = 217, 134, 149 - CannonPink = 145, 78, 117 - CaputMortuum = 89, 39, 32 - Caramel = 255, 213, 154 - Cararra = 237, 230, 214 - Cardinal = 179, 33, 52 - CardinGreen = 18, 53, 36 - CareysPink = 217, 152, 160 - CaribbeanGreen = 0, 222, 164 - Carmine = 175, 0, 42 - CarnationPink = 255, 166, 201 - CarrotOrange = 242, 142, 28 - Cascade = 141, 163, 153 - CatskillWhite = 226, 229, 222 - Cedar = 67, 48, 46 - Celadon = 172, 225, 175 - Celeste = 207, 207, 196 - Cello = 55, 79, 107 - Cement = 138, 121, 93 - Cerise = 222, 49, 99 - Cerulean = 0, 123, 167 - CeruleanBlue = 42, 82, 190 - Chantilly = 239, 187, 204 - Chardonnay = 255, 200, 124 - Charlotte = 167, 216, 222 - Charm = 208, 116, 139 - Chartreuse = 127, 255, 0 - ChartreuseYellow = 223, 255, 0 - ChelseaCucumber = 135, 169, 107 - Cherub = 246, 214, 222 - Chestnut = 185, 78, 72 - ChileanFire = 226, 88, 34 - Chinook = 150, 200, 162 - Chocolate = 210, 105, 30 - Christi = 125, 183, 0 - Christine = 181, 101, 30 - Cinnabar = 235, 76, 66 - Citron = 159, 169, 31 - Citrus = 141, 182, 0 - Claret = 95, 25, 51 - ClassicRose = 251, 204, 231 - ClayCreek = 145, 129, 81 - Clinker = 75, 54, 33 - Clover = 74, 93, 35 - Cobalt = 0, 71, 171 - CocoaBrown = 44, 22, 8 - Cola = 60, 48, 36 - ColumbiaBlue = 166, 231, 255 - CongoBrown = 103, 76, 71 - Conifer = 178, 236, 93 - Copper = 218, 138, 103 - CopperRose = 153, 102, 102 - Coral = 255, 127, 80 - CoralRed = 255, 64, 64 - CoralTree = 173, 111, 105 - Coriander = 188, 184, 138 - Corn = 251, 236, 93 - CornField = 250, 240, 190 - Cornflower = 147, 204, 234 - CornflowerBlue = 100, 149, 237 - Cornsilk = 255, 248, 220 - Cosmic = 132, 63, 91 - Cosmos = 255, 204, 203 - CostaDelSol = 102, 93, 30 - CottonCandy = 255, 188, 217 - Crail = 164, 90, 82 - Cranberry = 205, 96, 126 - Cream = 255, 255, 204 - CreamCan = 242, 198, 73 - Crimson = 220, 20, 60 - Crusta = 232, 142, 90 - Cumulus = 255, 255, 191 - Cupid = 246, 173, 198 - CuriousBlue = 40, 135, 200 - Cyan = 0, 255, 255 - Cyprus = 6, 78, 64 - DaisyBush = 85, 53, 146 - Dandelion = 250, 218, 94 - Danube = 96, 130, 182 - DarkBlue = 0, 0, 139 - DarkBrown = 101, 67, 33 - DarkCerulean = 8, 69, 126 - DarkChestnut = 152, 105, 96 - DarkCoral = 201, 90, 73 - DarkCyan = 0, 139, 139 - DarkGoldenrod = 184, 134, 11 - DarkGray = 169, 169, 169 - DarkGreen = 0, 100, 0 - DarkGreenCopper = 73, 121, 107 - DarkGrey = 169, 169, 169 - DarkKhaki = 189, 183, 107 - DarkMagenta = 139, 0, 139 - DarkOliveGreen = 85, 107, 47 - DarkOrange = 255, 140, 0 - DarkOrchid = 153, 50, 204 - DarkPastelGreen = 3, 192, 60 - DarkPink = 222, 93, 131 - DarkPurple = 150, 61, 127 - DarkRed = 139, 0, 0 - DarkSalmon = 233, 150, 122 - DarkSeaGreen = 143, 188, 143 - DarkSlateBlue = 72, 61, 139 - DarkSlateGray = 47, 79, 79 - DarkSlateGrey = 47, 79, 79 - DarkSpringGreen = 23, 114, 69 - DarkTangerine = 255, 170, 29 - DarkTurquoise = 0, 206, 209 - DarkViolet = 148, 0, 211 - DarkWood = 130, 102, 68 - DeepBlush = 245, 105, 145 - DeepCerise = 224, 33, 138 - DeepKoamaru = 51, 51, 102 - DeepLilac = 153, 85, 187 - DeepMagenta = 204, 0, 204 - DeepPink = 255, 20, 147 - DeepSea = 14, 124, 97 - DeepSkyBlue = 0, 191, 255 - DeepTeal = 24, 69, 59 - Denim = 36, 107, 206 - DesertSand = 237, 201, 175 - DimGray = 105, 105, 105 - DimGrey = 105, 105, 105 - DodgerBlue = 30, 144, 255 - Dolly = 242, 242, 122 - Downy = 95, 201, 191 - DutchWhite = 239, 223, 187 - EastBay = 76, 81, 109 - EastSide = 178, 132, 190 - EchoBlue = 169, 178, 195 - Ecru = 194, 178, 128 - Eggplant = 162, 0, 109 - EgyptianBlue = 16, 52, 166 - ElectricBlue = 125, 249, 255 - ElectricIndigo = 111, 0, 255 - ElectricLime = 208, 255, 20 - ElectricPurple = 191, 0, 255 - Elm = 47, 132, 124 - Emerald = 80, 200, 120 - Eminence = 108, 48, 130 - Endeavour = 46, 88, 148 - EnergyYellow = 245, 224, 80 - Espresso = 74, 44, 42 - Eucalyptus = 26, 162, 96 - Falcon = 126, 94, 96 - Fallow = 204, 153, 102 - FaluRed = 128, 24, 24 - Feldgrau = 77, 93, 83 - Feldspar = 205, 149, 117 - Fern = 113, 188, 120 - FernGreen = 79, 121, 66 - Festival = 236, 213, 64 - Finn = 97, 64, 81 - FireBrick = 178, 34, 34 - FireBush = 222, 143, 78 - FireEngineRed = 211, 33, 45 - Flamingo = 233, 92, 75 - Flax = 238, 220, 130 - FloralWhite = 255, 250, 240 - ForestGreen = 34, 139, 34 - Frangipani = 250, 214, 165 - FreeSpeechAquamarine = 0, 168, 119 - FreeSpeechRed = 204, 0, 0 - FrenchLilac = 230, 168, 215 - FrenchRose = 232, 83, 149 - FriarGrey = 135, 134, 129 - Froly = 228, 113, 122 - Fuchsia = 255, 0, 255 - FuchsiaPink = 255, 119, 255 - Gainsboro = 220, 220, 220 - Gallery = 219, 215, 210 - Galliano = 204, 160, 29 - Gamboge = 204, 153, 0 - Ghost = 196, 195, 208 - GhostWhite = 248, 248, 255 - Gin = 216, 228, 188 - GinFizz = 247, 231, 206 - Givry = 230, 208, 171 - Glacier = 115, 169, 194 - Gold = 255, 215, 0 - GoldDrop = 213, 108, 43 - GoldenBrown = 150, 113, 23 - GoldenFizz = 240, 225, 48 - GoldenGlow = 248, 222, 126 - GoldenPoppy = 252, 194, 0 - Goldenrod = 218, 165, 32 - GoldenSand = 233, 214, 107 - GoldenYellow = 253, 238, 0 - GoldTips = 225, 189, 39 - GordonsGreen = 37, 53, 41 - Gorse = 255, 225, 53 - Gossamer = 49, 145, 119 - GrannySmithApple = 168, 228, 160 - Gray = 128, 128, 128 - GrayAsparagus = 70, 89, 69 - Green = 0, 128, 0 - GreenLeaf = 76, 114, 29 - GreenVogue = 38, 67, 72 - GreenYellow = 173, 255, 47 - Grey = 128, 128, 128 - GreyAsparagus = 70, 89, 69 - GuardsmanRed = 157, 41, 51 - GumLeaf = 178, 190, 181 - Gunmetal = 42, 52, 57 - Hacienda = 155, 135, 12 - HalfAndHalf = 232, 228, 201 - HalfBaked = 95, 138, 139 - HalfColonialWhite = 246, 234, 190 - HalfPearlLusta = 240, 234, 214 - HanPurple = 63, 0, 255 - Harlequin = 74, 255, 0 - HarleyDavidsonOrange = 194, 59, 34 - Heather = 174, 198, 207 - Heliotrope = 223, 115, 255 - Hemp = 161, 122, 116 - Highball = 134, 126, 54 - HippiePink = 171, 75, 82 - Hoki = 110, 127, 128 - HollywoodCerise = 244, 0, 161 - Honeydew = 240, 255, 240 - Hopbush = 207, 113, 175 - HorsesNeck = 108, 84, 30 - HotPink = 255, 105, 180 - HummingBird = 201, 255, 229 - HunterGreen = 53, 94, 59 - Illusion = 244, 152, 173 - InchWorm = 202, 224, 13 - IndianRed = 205, 92, 92 - Indigo = 75, 0, 130 - InternationalKleinBlue = 0, 24, 168 - InternationalOrange = 255, 79, 0 - IrisBlue = 28, 169, 201 - IrishCoffee = 102, 66, 40 - IronsideGrey = 113, 112, 110 - IslamicGreen = 0, 144, 0 - Ivory = 255, 255, 240 - Jacarta = 61, 50, 93 - JackoBean = 65, 54, 40 - JacksonsPurple = 46, 45, 136 - Jade = 0, 171, 102 - JapaneseLaurel = 47, 117, 50 - Jazz = 93, 43, 44 - JazzberryJam = 165, 11, 94 - JellyBean = 68, 121, 142 - JetStream = 187, 208, 201 - Jewel = 0, 107, 60 - Jon = 79, 58, 60 - JordyBlue = 124, 185, 232 - Jumbo = 132, 132, 130 - JungleGreen = 41, 171, 135 - KaitokeGreen = 30, 77, 43 - Karry = 255, 221, 202 - KellyGreen = 70, 203, 24 - Keppel = 93, 164, 147 - Khaki = 240, 230, 140 - Killarney = 77, 140, 87 - KingfisherDaisy = 85, 27, 140 - Kobi = 230, 143, 172 - LaPalma = 60, 141, 13 - LaserLemon = 252, 247, 94 - Laurel = 103, 146, 103 - Lavender = 230, 230, 250 - LavenderBlue = 204, 204, 255 - LavenderBlush = 255, 240, 245 - LavenderPink = 251, 174, 210 - LavenderRose = 251, 160, 227 - LawnGreen = 124, 252, 0 - LemonChiffon = 255, 250, 205 - LightBlue = 173, 216, 230 - LightCoral = 240, 128, 128 - LightCyan = 224, 255, 255 - LightGoldenrodYellow = 250, 250, 210 - LightGray = 211, 211, 211 - LightGreen = 144, 238, 144 - LightGrey = 211, 211, 211 - LightPink = 255, 182, 193 - LightSalmon = 255, 160, 122 - LightSeaGreen = 32, 178, 170 - LightSkyBlue = 135, 206, 250 - LightSlateGray = 119, 136, 153 - LightSlateGrey = 119, 136, 153 - LightSteelBlue = 176, 196, 222 - LightYellow = 255, 255, 224 - Lilac = 204, 153, 204 - Lime = 0, 255, 0 - LimeGreen = 50, 205, 50 - Limerick = 139, 190, 27 - Linen = 250, 240, 230 - Lipstick = 159, 43, 104 - Liver = 83, 75, 79 - Lochinvar = 86, 136, 125 - Lochmara = 38, 97, 156 - Lola = 179, 158, 181 - LondonHue = 170, 152, 169 - Lotus = 124, 72, 72 - LuckyPoint = 29, 41, 81 - MacaroniAndCheese = 255, 189, 136 - Madang = 193, 249, 162 - Madras = 81, 65, 0 - Magenta = 255, 0, 255 - MagicMint = 170, 240, 209 - Magnolia = 248, 244, 255 - Mahogany = 215, 59, 62 - Maire = 27, 24, 17 - Maize = 230, 190, 138 - Malachite = 11, 218, 81 - Malibu = 93, 173, 236 - Malta = 169, 154, 134 - Manatee = 140, 146, 172 - Mandalay = 176, 121, 57 - MandarianOrange = 146, 39, 36 - Mandy = 191, 79, 81 - Manhattan = 229, 170, 112 - Mantis = 125, 194, 66 - Manz = 217, 230, 80 - MardiGras = 48, 25, 52 - Mariner = 57, 86, 156 - Maroon = 128, 0, 0 - Matterhorn = 85, 85, 85 - Mauve = 244, 187, 255 - Mauvelous = 255, 145, 175 - MauveTaupe = 143, 89, 115 - MayaBlue = 119, 181, 254 - McKenzie = 129, 97, 60 - MediumAquamarine = 102, 205, 170 - MediumBlue = 0, 0, 205 - MediumCarmine = 175, 64, 53 - MediumOrchid = 186, 85, 211 - MediumPurple = 147, 112, 219 - MediumRedViolet = 189, 51, 164 - MediumSeaGreen = 60, 179, 113 - MediumSlateBlue = 123, 104, 238 - MediumSpringGreen = 0, 250, 154 - MediumTurquoise = 72, 209, 204 - MediumVioletRed = 199, 21, 133 - MediumWood = 166, 123, 91 - Melon = 253, 188, 180 - Merlot = 112, 54, 66 - MetallicGold = 211, 175, 55 - Meteor = 184, 115, 51 - MidnightBlue = 25, 25, 112 - MidnightExpress = 0, 20, 64 - Mikado = 60, 52, 31 - MilanoRed = 168, 55, 49 - Ming = 54, 116, 125 - MintCream = 245, 255, 250 - MintGreen = 152, 255, 152 - Mischka = 168, 169, 173 - MistyRose = 255, 228, 225 - Moccasin = 255, 228, 181 - Mojo = 149, 69, 53 - MonaLisa = 255, 153, 153 - Mongoose = 179, 139, 109 - Montana = 53, 56, 57 - MoodyBlue = 116, 108, 192 - MoonYellow = 245, 199, 26 - MossGreen = 173, 223, 173 - MountainMeadow = 28, 172, 120 - MountainMist = 161, 157, 148 - MountbattenPink = 153, 122, 141 - Mulberry = 211, 65, 157 - Mustard = 255, 219, 88 - Myrtle = 25, 89, 5 - MySin = 255, 179, 71 - NavajoWhite = 255, 222, 173 - Navy = 0, 0, 128 - NavyBlue = 2, 71, 254 - NeonCarrot = 255, 153, 51 - NeonPink = 255, 92, 205 - Nepal = 145, 163, 176 - Nero = 20, 20, 20 - NewMidnightBlue = 0, 0, 156 - Niagara = 58, 176, 158 - NightRider = 59, 47, 47 - Nobel = 152, 152, 152 - Norway = 169, 186, 157 - Nugget = 183, 135, 39 - OceanGreen = 95, 167, 120 - Ochre = 202, 115, 9 - OldCopper = 111, 78, 55 - OldGold = 207, 181, 59 - OldLace = 253, 245, 230 - OldLavender = 121, 104, 120 - OldRose = 195, 33, 72 - Olive = 128, 128, 0 - OliveDrab = 107, 142, 35 - OliveGreen = 181, 179, 92 - Olivetone = 110, 110, 48 - Olivine = 154, 185, 115 - Onahau = 196, 216, 226 - Opal = 168, 195, 188 - Orange = 255, 165, 0 - OrangePeel = 251, 153, 2 - OrangeRed = 255, 69, 0 - Orchid = 218, 112, 214 - OuterSpace = 45, 56, 58 - OutrageousOrange = 254, 90, 29 - Oxley = 95, 167, 119 - PacificBlue = 0, 136, 220 - Padua = 128, 193, 151 - PalatinatePurple = 112, 41, 99 - PaleBrown = 160, 120, 90 - PaleChestnut = 221, 173, 175 - PaleCornflowerBlue = 188, 212, 230 - PaleGoldenrod = 238, 232, 170 - PaleGreen = 152, 251, 152 - PaleMagenta = 249, 132, 239 - PalePink = 250, 218, 221 - PaleSlate = 201, 192, 187 - PaleTaupe = 188, 152, 126 - PaleTurquoise = 175, 238, 238 - PaleVioletRed = 219, 112, 147 - PalmLeaf = 53, 66, 48 - Panache = 233, 255, 219 - PapayaWhip = 255, 239, 213 - ParisDaisy = 255, 244, 79 - Parsley = 48, 96, 48 - PastelGreen = 119, 221, 119 - PattensBlue = 219, 233, 244 - Peach = 255, 203, 164 - PeachOrange = 255, 204, 153 - PeachPuff = 255, 218, 185 - PeachYellow = 250, 223, 173 - Pear = 209, 226, 49 - PearlLusta = 234, 224, 200 - Pelorous = 42, 143, 189 - Perano = 172, 172, 230 - Periwinkle = 197, 203, 225 - PersianBlue = 34, 67, 182 - PersianGreen = 0, 166, 147 - PersianIndigo = 51, 0, 102 - PersianPink = 247, 127, 190 - PersianRed = 192, 54, 44 - PersianRose = 233, 54, 167 - Persimmon = 236, 88, 0 - Peru = 205, 133, 63 - Pesto = 128, 117, 50 - PictonBlue = 102, 153, 204 - PigmentGreen = 0, 173, 67 - PigPink = 255, 218, 233 - PineGreen = 1, 121, 111 - PineTree = 42, 47, 35 - Pink = 255, 192, 203 - PinkFlare = 191, 175, 178 - PinkLace = 240, 211, 220 - PinkSwan = 179, 179, 179 - Plum = 221, 160, 221 - Pohutukawa = 102, 12, 33 - PoloBlue = 119, 158, 203 - Pompadour = 129, 20, 83 - Portage = 146, 161, 207 - PotPourri = 241, 221, 207 - PottersClay = 132, 86, 60 - PowderBlue = 176, 224, 230 - Prim = 228, 196, 207 - PrussianBlue = 0, 58, 108 - PsychedelicPurple = 223, 0, 255 - Puce = 204, 136, 153 - Pueblo = 108, 46, 31 - PuertoRico = 67, 179, 174 - Pumpkin = 255, 99, 28 - Purple = 128, 0, 128 - PurpleMountainsMajesty = 150, 123, 182 - PurpleTaupe = 93, 57, 84 - QuarterSpanishWhite = 230, 224, 212 - Quartz = 220, 208, 255 - Quincy = 106, 84, 69 - RacingGreen = 26, 36, 33 - RadicalRed = 255, 32, 82 - Rajah = 251, 171, 96 - RawUmber = 123, 63, 0 - RazzleDazzleRose = 254, 78, 218 - Razzmatazz = 215, 10, 83 - Red = 255, 0, 0 - RedBerry = 132, 22, 23 - RedDamask = 203, 109, 81 - RedOxide = 99, 15, 15 - RedRobin = 128, 64, 64 - RichBlue = 84, 90, 167 - Riptide = 141, 217, 204 - RobinsEggBlue = 0, 204, 204 - RobRoy = 225, 169, 95 - RockSpray = 171, 56, 31 - RomanCoffee = 131, 105, 83 - RoseBud = 246, 164, 148 - RoseBudCherry = 135, 50, 96 - RoseTaupe = 144, 93, 93 - RosyBrown = 188, 143, 143 - Rouge = 176, 48, 96 - RoyalBlue = 65, 105, 225 - RoyalHeath = 168, 81, 110 - RoyalPurple = 102, 51, 152 - Ruby = 215, 24, 104 - Russet = 128, 70, 27 - Rust = 192, 64, 0 - RusticRed = 72, 6, 7 - Saddle = 99, 81, 71 - SaddleBrown = 139, 69, 19 - SafetyOrange = 255, 102, 0 - Saffron = 244, 196, 48 - Sage = 143, 151, 121 - Sail = 161, 202, 241 - Salem = 0, 133, 67 - Salmon = 250, 128, 114 - SandyBeach = 253, 213, 177 - SandyBrown = 244, 164, 96 - Sangria = 134, 1, 17 - SanguineBrown = 115, 54, 53 - SanMarino = 80, 114, 167 - SanteFe = 175, 110, 77 - Sapphire = 6, 42, 120 - Saratoga = 84, 90, 44 - Scampi = 102, 102, 153 - Scarlet = 255, 36, 0 - ScarletGum = 67, 28, 83 - SchoolBusYellow = 255, 216, 0 - Schooner = 139, 134, 128 - ScreaminGreen = 102, 255, 102 - Scrub = 59, 60, 54 - SeaBuckthorn = 249, 146, 69 - SeaGreen = 46, 139, 87 - Seagull = 140, 190, 214 - SealBrown = 61, 12, 2 - Seance = 96, 47, 107 - SeaPink = 215, 131, 127 - SeaShell = 255, 245, 238 - Selago = 250, 230, 250 - SelectiveYellow = 242, 180, 0 - SemiSweetChocolate = 107, 68, 35 - Sepia = 150, 90, 62 - Serenade = 255, 233, 209 - Shadow = 133, 109, 77 - Shakespeare = 114, 160, 193 - Shalimar = 252, 255, 164 - Shamrock = 68, 215, 168 - ShamrockGreen = 0, 153, 102 - SherpaBlue = 0, 75, 73 - SherwoodGreen = 27, 77, 62 - Shilo = 222, 165, 164 - ShipCove = 119, 139, 165 - Shocking = 241, 156, 187 - ShockingPink = 255, 29, 206 - ShuttleGrey = 84, 98, 111 - Sidecar = 238, 224, 177 - Sienna = 160, 82, 45 - Silk = 190, 164, 147 - Silver = 192, 192, 192 - SilverChalice = 175, 177, 174 - SilverTree = 102, 201, 146 - SkyBlue = 135, 206, 235 - SlateBlue = 106, 90, 205 - SlateGray = 112, 128, 144 - SlateGrey = 112, 128, 144 - Smalt = 0, 48, 143 - SmaltBlue = 74, 100, 108 - Snow = 255, 250, 250 - SoftAmber = 209, 190, 168 - Solitude = 235, 236, 240 - Sorbus = 233, 105, 44 - Spectra = 53, 101, 77 - SpicyMix = 136, 101, 78 - Spray = 126, 212, 230 - SpringBud = 150, 255, 0 - SpringGreen = 0, 255, 127 - SpringSun = 236, 235, 189 - SpunPearl = 170, 169, 173 - Stack = 130, 142, 132 - SteelBlue = 70, 130, 180 - Stiletto = 137, 63, 69 - Strikemaster = 145, 92, 131 - StTropaz = 50, 82, 123 - Studio = 115, 79, 150 - Sulu = 201, 220, 135 - SummerSky = 33, 171, 205 - Sun = 237, 135, 45 - Sundance = 197, 179, 88 - Sunflower = 228, 208, 10 - Sunglow = 255, 204, 51 - SunsetOrange = 253, 82, 64 - SurfieGreen = 0, 116, 116 - Sushi = 111, 153, 64 - SuvaGrey = 140, 140, 140 - Swamp = 35, 43, 43 - SweetCorn = 253, 219, 109 - SweetPink = 243, 153, 152 - Tacao = 236, 177, 118 - TahitiGold = 235, 97, 35 - Tan = 210, 180, 140 - Tangaroa = 0, 28, 61 - Tangerine = 228, 132, 0 - TangerineYellow = 253, 204, 13 - Tapestry = 183, 110, 121 - Taupe = 72, 60, 50 - TaupeGrey = 139, 133, 137 - TawnyPort = 102, 66, 77 - TaxBreak = 79, 102, 106 - TeaGreen = 208, 240, 192 - Teak = 176, 141, 87 - Teal = 0, 128, 128 - TeaRose = 255, 133, 207 - Temptress = 60, 20, 33 - Tenne = 200, 101, 0 - TerraCotta = 226, 114, 91 - Thistle = 216, 191, 216 - TickleMePink = 245, 111, 161 - Tidal = 232, 244, 140 - TitanWhite = 214, 202, 221 - Toast = 165, 113, 100 - Tomato = 255, 99, 71 - TorchRed = 255, 3, 62 - ToryBlue = 54, 81, 148 - Tradewind = 110, 174, 161 - TrendyPink = 133, 96, 136 - TropicalRainForest = 0, 127, 102 - TrueV = 139, 114, 190 - TulipTree = 229, 183, 59 - Tumbleweed = 222, 170, 136 - Turbo = 255, 195, 36 - TurkishRose = 152, 119, 123 - Turquoise = 64, 224, 208 - TurquoiseBlue = 118, 215, 234 - Tuscany = 175, 89, 62 - TwilightBlue = 253, 255, 245 - Twine = 186, 135, 89 - TyrianPurple = 102, 2, 60 - Ultramarine = 10, 17, 149 - UltraPink = 255, 111, 255 - Valencia = 222, 82, 70 - VanCleef = 84, 61, 55 - VanillaIce = 229, 204, 201 - VenetianRed = 209, 0, 28 - Venus = 138, 127, 128 - Vermilion = 251, 79, 20 - VeryLightGrey = 207, 207, 207 - VidaLoca = 94, 140, 49 - Viking = 71, 171, 204 - Viola = 180, 131, 149 - ViolentViolet = 50, 23, 77 - Violet = 238, 130, 238 - VioletRed = 255, 57, 136 - Viridian = 64, 130, 109 - VistaBlue = 159, 226, 191 - VividViolet = 127, 62, 152 - WaikawaGrey = 83, 104, 149 - Wasabi = 150, 165, 60 - Watercourse = 0, 106, 78 - Wedgewood = 67, 107, 149 - WellRead = 147, 61, 65 - Wewak = 255, 152, 153 - Wheat = 245, 222, 179 - Whiskey = 217, 154, 108 - WhiskeySour = 217, 144, 88 - White = 255, 255, 255 - WhiteSmoke = 245, 245, 245 - WildRice = 228, 217, 111 - WildSand = 229, 228, 226 - WildStrawberry = 252, 65, 154 - WildWatermelon = 255, 84, 112 - WildWillow = 172, 191, 96 - Windsor = 76, 40, 130 - Wisteria = 191, 148, 228 - Wistful = 162, 162, 208 - Yellow = 255, 255, 0 - YellowGreen = 154, 205, 50 - YellowOrange = 255, 174, 66 - YourPink = 244, 194, 194 -} \ No newline at end of file diff --git a/Public/ConvertTo-TeamsFact.ps1 b/Public/ConvertTo-TeamsFact.ps1 deleted file mode 100644 index 10d5025..0000000 --- a/Public/ConvertTo-TeamsFact.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -function ConvertTo-TeamsFact { - <# - .SYNOPSIS - Convert a PSCustomObject or a Hashtable to Teams facts. - - .DESCRIPTION - Teams facts are name-value pairs. This function helps convert a PSObject or a Hashtable to Teams facts (only one level deep). - - .PARAMETER InputObject - The Hashtable or PSObject that is output by another cmdlet. - - .EXAMPLE - Get-ChildItem | Select-Object -First 1 | ConvertTo-TeamsFact - - .EXAMPLE - @{ Product = 'Microsoft Teams'; Developer = 'Microsoft Corporation'; ReleaseYear = '2018' } | ConvertTo-TeamsFact - - .NOTES - Ram Iyer (https://ramiyer.me) - #> - - [CmdletBinding()] - param ( - # The input object - [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)] - $InputObject - ) - foreach ($Object in $InputObject) { - if ($Object -is [System.Collections.IDictionary]) { - $Facts = foreach ($Key in $Object.Keys) { - New-TeamsFact -Name $Key -Value $Object.$Key - } - #} elseif (($Object -is [int]) -or ($Object -is [long]) -or ($Object -is [string]) -or ($Object -is [char]) -or ($Object -is [bool]) -or ($Object -is [byte]) -or ($Object -is [double]) -or ($Object -is [decimal]) -or ($Object -is [single]) -or ($Object -is [array]) -or ($Object -is [xml])) { - } elseif ($Object.GetType().Name -match 'bool|byte|char|datetime|decimal|double|xml|float|int|long|sbyte|short|string|timespan|uint|ulong|URI|ushort') { - # Because PowerShell implicitly converts datatypes to PSObject - Write-Error -Message 'The input is neither a PSObject nor a Hashtable. Operation aborted.' -Category InvalidData -ErrorAction Stop - } else { - # Assumes that the input is a PSObject; anyway there would be an implicit conversion if not caught in the previous block - $Facts = foreach ($Property in $Object.PsObject.Properties) { - New-TeamsFact -Name $Property.Name -Value $Property.Value - } - } - $Facts - } -} \ No newline at end of file diff --git a/Public/ConvertTo-TeamsSection.ps1 b/Public/ConvertTo-TeamsSection.ps1 deleted file mode 100644 index 737821e..0000000 --- a/Public/ConvertTo-TeamsSection.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -function ConvertTo-TeamsSection { - <# - .SYNOPSIS - Convert an array of PSCustomObject or a Hashtable to separate Teams sections. - - .DESCRIPTION - Teams sections are chunks of information that appear within a Teams message. This function helps convert an array of PSObject or an array of Hashtables to Teams sections (only one level deep). - - .PARAMETER InputObject - The Hashtable or PSObject that is output by another cmdlet. - - .EXAMPLE - Get-ChildItem -Directory | ConvertTo-TeamsSection -SectionTitleProperty Name - - .NOTES - Ram Iyer (https://ramiyer.me) - #> - param ( - # The input object - [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)] - $InputObject, - - # The property to use for title - [Parameter(Mandatory = $false, Position = 1)] - [string] - $SectionTitleProperty - ) - - process { - #$TotalCount = $InputObject.Count - #$CurrentCount = 1 - - foreach ($Item in $InputObject) { - $SectionParams = @{ - ActivityDetails = $Item | ConvertTo-TeamsFact - } - if ($SectionTitleProperty) { - $SectionParams.ActivityTitle = "$(($SectionTitleProperty -creplace '([A-Z])', ' $1').Trim()) $($Item.$SectionTitleProperty)" - } - New-TeamsSection @SectionParams - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveAction.ps1 b/Public/New-AdaptiveAction.ps1 deleted file mode 100644 index de5e0ba..0000000 --- a/Public/New-AdaptiveAction.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -function New-AdaptiveAction { - [cmdletBinding()] - param( - [scriptblock] $Body, - [scriptblock] $Actions, - [ValidateSet('Action.ShowCard', 'Action.Submit', 'Action.OpenUrl', 'Action.ToggleVisibility')][string] $Type = 'Action.ShowCard', - [string] $ActionUrl, - [string] $Title - ) - if ($ActionUrl) { - # We help user so the actioon choses itself - $Type = 'Action.OpenUrl' - } - $TeamObject = [ordered] @{ - type = $Type - title = $Title - url = $ActionUrl - card = [ordered]@{} - } - if ($Body -or $Actions) { - $TeamObject['card']['type'] = 'AdaptiveCard' - if ($Body) { - $TeamObject['card']['body'] = & $Body - } - if ($Actions) { - $TeamObject['card']['actions'] = & $Actions - } - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject -} \ No newline at end of file diff --git a/Public/New-AdaptiveActionSet.ps1 b/Public/New-AdaptiveActionSet.ps1 deleted file mode 100644 index a53460d..0000000 --- a/Public/New-AdaptiveActionSet.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -function New-AdaptiveActionSet { - [cmdletBinding()] - param( - [scriptblock] $Action - ) - - if ($Action) { - $OutputAction = & $Action - if ($OutputAction) { - $TeamObject = [ordered] @{ - type = 'ActionSet' - actions = @( - $OutputAction - ) - } - Remove-EmptyValue -Hashtable $TeamObject - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveCard.ps1 b/Public/New-AdaptiveCard.ps1 deleted file mode 100644 index d8dba16..0000000 --- a/Public/New-AdaptiveCard.ps1 +++ /dev/null @@ -1,311 +0,0 @@ -function New-AdaptiveCard { - <# - .SYNOPSIS - An Adaptive Card, containing a free-form body of card elements, and an optional set of actions. - - .DESCRIPTION - An Adaptive Card, containing a free-form body of card elements, and an optional set of actions. - - .PARAMETER Body - The card elements to show in the primary card region. - - .PARAMETER Action - The Actions to show in the card's action bar. - - .PARAMETER Uri - WebHook Uri to send Adaptive Card to. When provided sends Adaptive Card. When not provided JSON is returned. - - .PARAMETER FallBackText - Text shown when the client doesn't support the version specified (may contain markdown). - - .PARAMETER MinimumHeight - Specifies the minimum height of the card. - - .PARAMETER Speak - Specifies what should be spoken for this entire card. This is simple text or SSML fragment. - - .PARAMETER Language - The 2-letter ISO-639-1 language used in the card. Used to localize any date/time functions. - - .PARAMETER VerticalContentAlignment - Defines how the content should be aligned vertically within the container. Only relevant for fixed-height cards, or cards with a minHeight specified. - - .PARAMETER BackgroundUrl - Specifies a background image. Acceptable formats are PNG, JPEG, and GIF - - .PARAMETER BackgroundFillMode - Controls how background is displayed - - "cover": The background image covers the entire width of the container. Its aspect ratio is preserved. Content may be clipped if the aspect ratio of the image doesn't match the aspect ratio of the container. verticalAlignment is respected (horizontalAlignment is meaningless since it's stretched width). This is the default mode and is the equivalent to the current model. - "repeatHorizontally": The background image isn't stretched. It is repeated in the x axis as many times as necessary to cover the container's width. verticalAlignment is honored (default is top), horizontalAlignment is ignored. - "repeatVertically": The background image isn't stretched. It is repeated in the y axis as many times as necessary to cover the container's height. verticalAlignment is ignored, horizontalAlignment is honored (default is left). - "repeat": The background image isn't stretched. It is repeated first in the x axis then in the y axis as many times as necessary to cover the entire container. Both horizontalAlignment and verticalAlignment are honored (defaults are left and top). - - .PARAMETER BackgroundHorizontalAlignment - Controls how background is aligned horizontally - - .PARAMETER BackgroundVerticalAlignment - Controls how background is aligned vertically - - .PARAMETER SelectAction - An Action that will be invoked when the card is tapped or selected. - - .PARAMETER SelectActionId - Provide ID for Select Action - - .PARAMETER SelectActionUrl - Provide URL to open when using SelectAction with Action.OpenUrl - - .PARAMETER SelectActionTitle - Provide Title for Select Action - - .PARAMETER FullWidth - Provide ability to make card full width. By default it set to small. - - .PARAMETER AllowImageExpand - Defines that image may expand - - .PARAMETER ReturnJson - Defines that JSON should be returned even when Uri is provided. Useful for debugging - - .EXAMPLE - New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { - New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Dark - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Light - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Good - } - } - } -SelectAction Action.OpenUrl -SelectActionUrl 'https://evotec.xyz' -Verbose - - .EXAMPLE - New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { - New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Dark - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Light - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Good - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1 Name' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1 Zenon Jaskuła' -Color Warning - } - } - New-AdaptiveMention -Text 'Zenon Jaskuła' -UserPrincipalName 'przemyslaw.klys@evotec.test' - New-AdaptiveMention -Text 'Name' -UserPrincipalName 'przemyslaw.klys@evotec.test' - } -Verbose -FullWidth - - .NOTES - General notes - #> - [cmdletBinding()] - param( - [scriptblock] $Body, - [scriptblock] $Action, - [string] $Uri, - [string] $FallBackText, - [int] $MinimumHeight, - [string] $Speak, - [string] $Language, - [ValidateSet('top', 'center', 'bottom')][string] $VerticalContentAlignment, - - [string] $BackgroundUrl, - [ValidateSet('Cover', 'RepeatHorizontally', 'RepeatVertically', 'Repeat')][string] $BackgroundFillMode, - [ValidateSet('left', 'center', 'right')][string] $BackgroundHorizontalAlignment, - [ValidateSet('top', 'center', 'bottom')][string] $BackgroundVerticalAlignment, - - [ValidateSet('Action.Submit', 'Action.OpenUrl', 'Action.ToggleVisibility')][string] $SelectAction, - [string] $SelectActionId, - [string] $SelectActionUrl, - [string] $SelectActionTitle, - [switch] $FullWidth, - [switch] $AllowImageExpand, - [switch] $ReturnJson - ) - $Mentions = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $Wrapper = [ordered]@{ - "type" = "message" - "attachments" = @( - [ordered] @{ - "contentType" = 'application/vnd.microsoft.card.adaptive' - "content" = [ordered]@{ - '$schema' = "http://adaptivecards.io/schemas/adaptive-card.json" - type = "AdaptiveCard" - version = "1.2" # Currently maximum supported is 1.2 for Teams, available is 1.3 - body = @( - if ($Body) { - $OutputBody = & $Body - foreach ($B in $OutputBody) { - if ($B.type -eq 'mention') { - $Mentions.Add($B) - } else { - $B - } - } - } - ) - actions = @( - if ($Action) { - & $Action - } - ) - msteams = [ordered]@{ - - } - } - } - ) - } - if ($AllowImageExpand) { - $Wrapper['attachments'][0]['content']['msteams']['allowExpand'] = $true - } - if ($FullWidth) { - $Wrapper['attachments'][0]['content']['msteams']['width'] = 'Full' - } - if ($MinimumHeight) { - $Wrapper['attachments'][0]['content']['minHeight'] = "$($MinimumHeight)px" - } - # if ($FallBackText) { - $Wrapper['attachments'][0]['content']['fallbackText'] = $FallBackText - # } - # if ($Language) { - $Wrapper['attachments'][0]['content']['lang'] = $Language - # } - # if ($Speak) { - $Wrapper['attachments'][0]['content']['speak'] = $Speak - # } - # if ($VerticalContentAlignment) { - $Wrapper['attachments'][0]['content']['verticalContentAlignment'] = $VerticalContentAlignment - #} - #if ($BackgroundUrl) { - $Wrapper['attachments'][0]['content']['backgroundImage'] = [ordered] @{ - "fillMode" = $BackgroundFillMode - "horizontalAlignment" = $BackgroundHorizontalAlignment - "verticalAlignment" = $BackgroundVerticalAlignment - "url" = $BackgroundUrl - } - #} - if ($SelectActionUrl) { - # We help user so the actioon choses itself - $SelectAction = 'Action.OpenUrl' - } - $Wrapper['attachments'][0]['content']['selectAction'] = [ordered] @{ - type = $SelectAction - id = $SelectActionId - title = $SelectActionTitle - url = $SelectActionUrl - } - - if ($Mentions.Count -gt 0) { - # this somewhat works, except it doesn't - $Wrapper['attachments'][0]['content']["msteams"]["entities"] = @( - foreach ($Mention in $Mentions) { - $Mention - } - <# - @{ - "type" = "mention" - "text" = "przemyslaw.klys" - "mentioned" = @{ - #"id" = "8:orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d" - #"id" = '29:49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"id" = '29:orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"id" = '19:b6b525a2187848ddb257f59e374363bd' - #"id" = 'orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"id" = '49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"name" = "przemyslaw.klys" - - id = 'przemyslaw.klys@evotec.pl' - name = 'Przemysław Kłys' - - } - } - #> - ) - } - <# - #> - <# this doesn't work, but tested - $Wrapper["msteams"] = @{ - "entities" = @( - @{ - "type" = "mention" - "text" = "przemyslaw.klys" - "mentioned" = @{ - "id" = "8:orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d" - #"id" = '29:49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"id" = 'orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d' - #"id" = '49f7e27a-ce6c-45ef-9936-6ef3e940583d' - "name" = "przemyslaw.klys" - } - } - ) - } - #> - - Remove-EmptyValue -Hashtable $Wrapper['attachments'][0]['content'] -Recursive -Rerun 1 - $JsonBody = $Wrapper | ConvertTo-JsonLiteral -Depth 20 #ConvertTo-Json -Depth 20 - #$New = $JsonBody | Format-Json -Indentation 4 - # $JsonBody = $Wrapper | ConvertTo-Json -Depth 20 - # If URI is not given we return JSON. This is because it's possible to use nested Adaptive Cards in actions - if ($Uri) { - Send-TeamsMessageBody -Uri $URI -Body $JsonBody #-Verbose - if ($ReturnJson) { - $JsonBody - } - } else { - $JsonBody - } -} - -<# - "channelId" = @{ - "entities" = @( - @{ - "type" = "mention" - "text" = "Name" - "mentioned" = @{ - "id" = "29:124124124124" - "name" = "Mungo" - } - } - ) - } -#> - - -#"msteams" = @{ -#"entities" = @( -<# - @{ - "type" = "mention" - "text" = "przemyslawklys" - "mentioned" = @{ - "id" = "8:orgid:49f7e27a-ce6c-45ef-9936-6ef3e940583d" - "name" = "przemyslawklys" - } - } - #> -# Azure ID: 49f7e27a-ce6c-45ef-9936-6ef3e940583d -# AD GUID: d425e1e4-d6b3-4e58-bb24-f96c995fd3a0 -<# - @{ - "type" = "mention" - "text" = "Przemysław" - "mentioned" = @{ - "id" = "29:124124124124" - "name" = "Mungo" - } - } - #> -#) -#} \ No newline at end of file diff --git a/Public/New-AdaptiveColumn.ps1 b/Public/New-AdaptiveColumn.ps1 deleted file mode 100644 index b98685c..0000000 --- a/Public/New-AdaptiveColumn.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -function New-AdaptiveColumn { - [cmdletBinding()] - param( - [scriptblock] $Items, - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - [ValidateSet('Stretch', 'Auto', 'Weighted')][string] $Width, - [int] $WidthInWeight, - [int] $WidthInPixels, - [int] $MinimumHeight, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Top', 'Center', 'Bottom')][string] $VerticalContentAlignment, - [ValidateSet("Accent", 'Default', 'Emphasis', 'Good', 'Warning', 'Attention')][string] $Style, - [switch] $Hidden, - [switch] $Separator, - - [ValidateSet('Action.Submit', 'Action.OpenUrl', 'Action.ToggleVisibility')][string] $SelectAction, - [string] $SelectActionId, - [string] $SelectActionUrl, - [string] $SelectActionTitle, - [string[]] $SelectActionTargetElement - ) - if ($WidthInWeight) { - $WidthValue = "$($WidthInWeight)" - # it actually forces $Width = Weighted but it's not in JSON - } elseif ($WidthInPixels) { - $WidthValue = "$($WidthInPixels)px" - } else { - # Width value pixels is not displayed - # it seems width requires lowerCase values which is weird for Microsoft - $WidthValue = $Width.ToLower() - } - - if ($Items) { - $OutputItems = & $Items - if ($OutputItems) { - $TeamObject = [ordered] @{ - type = 'Column' - width = $WidthValue - height = $Height - items = @( - $OutputItems - ) - horizontalAlignment = $HorizontalAlignment - verticalContentAlignment = $VerticalContentAlignment - spacing = $Spacing - style = $Style - } - if ($MinimumHeight) { - $TeamObject['minHeight'] = "$($MinimumHeight)px" - } - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - if ($SelectActionUrl) { - # We help user so the actioon choses itself - $SelectAction = 'Action.OpenUrl' - } - $TeamObject['selectAction'] = [ordered] @{ - type = $SelectAction - id = $SelectActionId - title = $SelectActionTitle - url = $SelectActionUrl - } - if ($SelectActionTargetElement) { - # We help user so the actioon choses itself - $TeamObject['selectAction']['type'] = 'Action.ToggleVisibility' - # We add missing data - $TeamObject['selectAction']['targetElements'] = @( - $SelectActionTargetElement - ) - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveColumnSet.ps1 b/Public/New-AdaptiveColumnSet.ps1 deleted file mode 100644 index 075a73c..0000000 --- a/Public/New-AdaptiveColumnSet.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -function New-AdaptiveColumnSet { - [cmdletBinding()] - param( - [scriptblock] $Columns, - [ValidateSet("Accent", 'Default', 'Emphasis', 'Good', 'Warning', 'Attention')][string] $Style, - [int] $MinimumHeight, - [switch] $Bleed, - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height - # Layout End - ) - if ($Columns) { - $ColumnsOutput = & $Columns - if ($ColumnsOutput) { - $TeamObject = [ordered] @{ - "type" = "ColumnSet" - "columns" = @( - $ColumnsOutput - ) - "style" = $Style - - # Layout - "horizontalAlignment" = $HorizontalAlignment - "height" = $Height - "spacing" = $Spacing - } - if ($Bleed) { - $TeamObject['bleed'] = $true - } - if ($MinimumHeight) { - $TeamObject['minHeight'] = "$($MinimumHeight)px" - } - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - if ($SelectActionUrl) { - # We help user so the actioon choses itself - $SelectAction = 'Action.OpenUrl' - } - $TeamObject['selectAction'] = [ordered] @{ - type = $SelectAction - id = $SelectActionId - title = $SelectActionTitle - url = $SelectActionUrl - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveContainer.ps1 b/Public/New-AdaptiveContainer.ps1 deleted file mode 100644 index 2b76955..0000000 --- a/Public/New-AdaptiveContainer.ps1 +++ /dev/null @@ -1,85 +0,0 @@ -function New-AdaptiveContainer { - [cmdletBinding()] - param( - [scriptblock] $Items, - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - # Layout End - [ValidateSet("Accent", 'Default', 'Emphasis', 'Good', 'Warning', 'Attention')][string] $Style, - [int] $MinimumHeight, - [switch] $Bleed, - [ValidateSet('top', 'center', 'bottom')][string] $VerticalContentAlignment, - [string] $Id, - [switch] $Hidden, - - [string] $BackgroundUrl, - [ValidateSet('Cover', 'RepeatHorizontally', 'RepeatVertically', 'Repeat')][string] $BackgroundFillMode, - [ValidateSet('left', 'center', 'right')][string] $BackgroundHorizontalAlignment, - [ValidateSet('top', 'center', 'bottom')][string] $BackgroundVerticalAlignment, - - [ValidateSet('Action.Submit', 'Action.OpenUrl', 'Action.ToggleVisibility')][string] $SelectAction, - [string] $SelectActionId, - [string] $SelectActionUrl, - [string] $SelectActionTitle, - [string[]] $SelectActionTargetElement - ) - if ($Items) { - $OutputItems = & $Items - if ($OutputItems) { - $TeamObject = [ordered] @{ - type = "Container" - id = $Id - items = @( - $OutputItems - ) - style = $Style - verticalContentAlignment = $verticalContentAlignment - # Layout - horizontalAlignment = $HorizontalAlignment - height = $Height - spacing = $Spacing - } - if ($Bleed) { - $TeamObject['bleed'] = $true - } - if ($MinimumHeight) { - $TeamObject['minHeight'] = "$($MinimumHeight)px" - } - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - $TeamObject['backgroundImage'] = [ordered] @{ - "fillMode" = $BackgroundFillMode - "horizontalAlignment" = $BackgroundHorizontalAlignment - "verticalAlignment" = $BackgroundVerticalAlignment - "url" = $BackgroundUrl - } - if ($SelectActionUrl) { - # We help user so the actioon choses itself - $SelectAction = 'Action.OpenUrl' - } - $TeamObject['selectAction'] = [ordered] @{ - type = $SelectAction - id = $SelectActionId - title = $SelectActionTitle - url = $SelectActionUrl - } - if ($SelectActionTargetElement) { - # We help user so the actioon choses itself - $TeamObject['selectAction']['type'] = 'Action.ToggleVisibility' - # We add missing data - $TeamObject['selectAction']['targetElements'] = @( - $SelectActionTargetElement - ) - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveFact.ps1 b/Public/New-AdaptiveFact.ps1 deleted file mode 100644 index 0805493..0000000 --- a/Public/New-AdaptiveFact.ps1 +++ /dev/null @@ -1,14 +0,0 @@ -function New-AdaptiveFact { - [cmdletBinding()] - param( - [string] $Title, - [string] $Value - ) - - $Fact = [ordered] @{ - title = "$Title" - value = "$Value" - #type = 'fact' # this is only needed for module to process this correctly. JSON doesn't care - } - $Fact -} \ No newline at end of file diff --git a/Public/New-AdaptiveFactSet.ps1 b/Public/New-AdaptiveFactSet.ps1 deleted file mode 100644 index bfab3da..0000000 --- a/Public/New-AdaptiveFactSet.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -function New-AdaptiveFactSet { - [cmdletBinding()] - param( - [scriptblock] $Facts, - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - [switch] $Separator - ) - if ($Facts) { - $OutputFacts = & $Facts - if ($OutputFacts) { - $TeamObject = [ordered] @{ - type = 'FactSet' - height = $Height - spacing = $Spacing - facts = @($OutputFacts) - } - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - Remove-EmptyValue -Hashtable $TeamObject - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveImage.ps1 b/Public/New-AdaptiveImage.ps1 deleted file mode 100644 index a372767..0000000 --- a/Public/New-AdaptiveImage.ps1 +++ /dev/null @@ -1,162 +0,0 @@ -function New-AdaptiveImage { - <# - .SYNOPSIS - Displays an image. Acceptable formats are PNG, JPEG, and GIF - - .DESCRIPTION - Displays an image. Acceptable formats are PNG, JPEG, and GIF - - .PARAMETER Url - The URL to the image. - - .PARAMETER Style - Controls how this Image is displayed. - - .PARAMETER AlternateText - Alternate text describing the image. - - .PARAMETER Size - Controls the approximate size of the image. The physical dimensions will vary per host. - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Separator - Draw a separating line at the top of the element. - - .PARAMETER HorizontalAlignment - Controls how this element is horizontally positioned within its parent. - - .PARAMETER Height - The desired height of the image. - - .PARAMETER HeightInPixels - The desired height of the image. Will be specified in pixel value. The image will distort to fit that exact height. This overrides the size property. - - .PARAMETER WidthInPixels - The desired on-screen width of the image. This overrides the size property. - - .PARAMETER Id - A unique identifier associated with the item. - - .PARAMETER Hidden - If used this item will be removed from the visual tree. - - .PARAMETER BackgroundColor - Applies a background to a transparent image. This property will respect the image style. - - .PARAMETER SelectAction - An Action that will be invoked when the card is tapped or selected. - - .PARAMETER SelectActionId - Provide ID for Select Action - - .PARAMETER SelectActionUrl - Provide URL to open when using SelectAction with Action.OpenUrl - - .PARAMETER SelectActionTitle - Provide Title for Select Action - - .EXAMPLE - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - - .EXAMPLE - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - - .EXAMPLE - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person -SelectAction Action.OpenUrl -SelectActionUrl 'https://evotec.xyz' - - .EXAMPLE - New-HeroImage -Url 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Seattle_monorail01_2008-02-25.jpg/1024px-Seattle_monorail01_2008-02-25.jpg' - - .EXAMPLE - New-ThumbnailImage -Url 'https://upload.wikimedia.org/wikipedia/en/a/a6/Bender_Rodriguez.png' -AltText "Bender Rodríguez" - - .NOTES - Adaptive Image supports most if not all of those options. However HeroImage and ThumbnailImage most likely support only some if not just what is shown in Examples. - I didn't want to create additional functions just for the sake of having more of them, as I expect most people using Adaptive Cards, and occasionally other types. - - #> - [alias('New-HeroImage', 'New-ThumbnailImage')] - [cmdletBinding()] - param( - [alias('Link')][string] $Url, - [ValidateSet('person', 'default')][string] $Style, - [alias('Alt', 'AltText')][string] $AlternateText, - [ValidateSet('Auto', 'Stretch', 'Small', 'Medium', 'Large')][string] $Size, - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - [int] $HeightInPixels, - [int] $WidthInPixels, - # Layout End - [string] $Id, - [switch] $Hidden, - [string] $BackgroundColor, - # SelectAction - [ValidateSet('Action.Submit', 'Action.OpenUrl', 'Action.ToggleVisibility')][string] $SelectAction, - [string] $SelectActionId, - [string] $SelectActionUrl, - [string] $SelectActionTitle, - [string[]] $SelectActionTargetElement - ) - $TeamObject = [ordered] @{ - type = 'Image' - id = $Id - url = $Url - size = $Size - alt = $AlternateText - style = $Style - # Start Layout - horizontalAlignment = $HorizontalAlignment - height = $Height - spacing = $Spacing - # End Layout - backgroundColor = ConvertFrom-Color -Color $BackgroundColor - } - # Start Layout - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - # End Layout - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - if ($WidthInPixels) { - $TeamObject['width'] = "$($WidthInPixels)px" - } - if ($HeightInPixels) { - $TeamObject['height'] = "$($HeightInPixels)px" - } else { - $TeamObject['height'] = $Height - } - if ($SelectActionUrl) { - # We help user so the actioon choses itself - $SelectAction = 'Action.OpenUrl' - } - $TeamObject['selectAction'] = [ordered] @{ - type = $SelectAction - id = $SelectActionId - title = $SelectActionTitle - url = $SelectActionUrl - } - if ($SelectActionTargetElement) { - # We help user so the actioon choses itself - $TeamObject['selectAction']['type'] = 'Action.ToggleVisibility' - # We add missing data - $TeamObject['selectAction']['targetElements'] = @( - $SelectActionTargetElement - ) - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject -} - -$Script:ScriptBlockColors = { - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - $Script:RGBColors.Keys | Where-Object { $_ -like "*$wordToComplete*" } -} - -Register-ArgumentCompleter -CommandName New-AdaptiveImage -ParameterName BackgroundColor -ScriptBlock $Script:ScriptBlockColors \ No newline at end of file diff --git a/Public/New-AdaptiveImageSet.ps1 b/Public/New-AdaptiveImageSet.ps1 deleted file mode 100644 index cf1daa6..0000000 --- a/Public/New-AdaptiveImageSet.ps1 +++ /dev/null @@ -1,101 +0,0 @@ -function New-AdaptiveImageSet { - <# - .SYNOPSIS - The ImageSet displays a collection of Images similar to a gallery. Acceptable formats are PNG, JPEG, and GIF - - .DESCRIPTION - The ImageSet displays a collection of Images similar to a gallery. Acceptable formats are PNG, JPEG, and GIF - - .PARAMETER Images - List of images - - .PARAMETER Size - Controls size of all images in gallery - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Separator - Draw a separating line at the top of the element. - - .PARAMETER HorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER Height - Specifies the height of the element. - - .PARAMETER Id - A unique identifier associated with the item. - - .PARAMETER Hidden - If used this item will be removed from the visual tree. - - .EXAMPLE - New-AdaptiveImageGallery { - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Style person - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Style person - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Style person - } -HorizontalAlignment Right -Size Large - - .EXAMPLE - New-AdaptiveImageGallery { - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - } - - .EXAMPLE - New-AdaptiveImageGallery { - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - New-AdaptiveImage -BackgroundColor AlbescentWhite -Url 'https://devblogs.microsoft.com/powershell/wp-content/uploads/sites/30/2018/09/Powershell_256.png' - } -Size Small - - .NOTES - General notes - #> - [alias('New-AdaptiveImageGallery')] - [cmdletBinding()] - param( - [scriptblock] $Images, - [ValidateSet('Small', 'Medium', 'Large')][string] $Size, - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - # Layout End - [string] $Id, - [switch] $Hidden - ) - if ($Images) { - $OutputImages = & $Images - if ($OutputImages) { - $TeamObject = [ordered] @{ - type = 'ImageSet' - images = @($OutputImages) - id = $Id - imageSize = $Size - # Start Layout - horizontalAlignment = $HorizontalAlignment - height = $Height - spacing = $Spacing - # End Layout - } - # Start Layout - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - # End Layout - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - Remove-EmptyValue -Hashtable $TeamObject -Recursive -Rerun 1 - $TeamObject - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveLineBreak.ps1 b/Public/New-AdaptiveLineBreak.ps1 deleted file mode 100644 index 7b53c3c..0000000 --- a/Public/New-AdaptiveLineBreak.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -function New-AdaptiveLineBreak { - [cmdletBinding()] - param( - - ) - New-AdaptiveTextBlock -Text "`n" -} \ No newline at end of file diff --git a/Public/New-AdaptiveMedia.ps1 b/Public/New-AdaptiveMedia.ps1 deleted file mode 100644 index 0d185e8..0000000 --- a/Public/New-AdaptiveMedia.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -function New-AdaptiveMedia { - <# - .SYNOPSIS - Displays a media player for audio or video content. - - .DESCRIPTION - Displays a media player for audio or video content. - - .PARAMETER Sources - One or more sources of media to attempt to play. - - .PARAMETER PosterUrl - URL of an image to display before playing - - .PARAMETER AlternateText - Alternate text describing the audio or video. - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Separator - Draw a separating line at the top of the element. - - .PARAMETER HorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER Height - Specifies the height of the element. - - .PARAMETER Id - A unique identifier associated with the item. - - .PARAMETER Hidden - If used this item will be removed from the visual tree. - - .EXAMPLE - New-AdaptiveMedia -PosterUrl 'https://adaptivecards.io/content/poster-video.png' { - New-AdaptiveMediaSource -Type "video/mp4" -Url "https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4" - New-AdaptiveMediaSource -Type "video/mp4" -Url "https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4" - } - - .NOTES - Media playback is currently not supported in Adaptive Cards in Teams. Adding it for sake of having. - May need to improve how it's handled. - - #> - [cmdletBinding()] - param( - [parameter(Mandatory)][scriptblock] $Sources, - [string] $PosterUrl, - [string] $AlternateText, - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - # Layout End, - [string] $Id, - [switch] $Hidden - ) - if ($Sources) { - $TeamObject = [ordered] @{ - type = 'Media' - poster = $PosterUrl - id = $Id - altText = $AlternateText - # Start Layout - horizontalAlignment = $HorizontalAlignment - height = $Height - spacing = $Spacing - # End Layout - } - # Start Layout - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - # End Layout - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - $TeamObject['sources'] = @( - & $Sources - ) - Remove-EmptyValue -Hashtable $TeamObject - $TeamObject - } -} diff --git a/Public/New-AdaptiveMediaSource.ps1 b/Public/New-AdaptiveMediaSource.ps1 deleted file mode 100644 index 9f0d039..0000000 --- a/Public/New-AdaptiveMediaSource.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -function New-AdaptiveMediaSource { - <# - .SYNOPSIS - Defines a source for a Media element - - .DESCRIPTION - Defines a source for a Media element - - .PARAMETER Type - Mime type of associated media (e.g. "video/mp4"). - - .PARAMETER Url - URL to media. - - .EXAMPLE - New-AdaptiveMediaSource -Type "video/mp4" -Url "https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4" - - .EXAMPLE - New-AdaptiveMedia -PosterUrl 'https://adaptivecards.io/content/poster-video.png' { - New-AdaptiveMediaSource -Type "video/mp4" -Url "https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4" - New-AdaptiveMediaSource -Type "video/mp4" -Url "https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4" - } - - .NOTES - Media playback is currently not supported in Adaptive Cards in Teams. Adding it for sake of having. - May need to improve how it's handled. - - #> - [cmdletBinding()] - param( - [string] $Type, - [string] $Url - ) - $TeamObject = [ordered] @{ - mimeType = $Type - url = $Url - } - $TeamObject -} \ No newline at end of file diff --git a/Public/New-AdaptiveMention.ps1 b/Public/New-AdaptiveMention.ps1 deleted file mode 100644 index 0d82114..0000000 --- a/Public/New-AdaptiveMention.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function New-AdaptiveMention { - <# - .SYNOPSIS - Allows Adaptive Card to mention specific person in the notification. - - .DESCRIPTION - Allows Adaptive Card to mention specific person in the notification. - Currently Adaptive Card in incoming webhook notifications allows you to define a mention property, but it doesn't notify the user on notification. - It's supposed to be enabled in future by Microsoft. - - .PARAMETER Text - Provide the text to be mentioned by using person tag. You can provide tags or skip them. Should work either way. - - .PARAMETER UserPrincipalName - Provide the user principal name of the person to be mentioned. - - .PARAMETER Name - Provide the name of the person to be mentioned. This is optional. - - .EXAMPLE - New-AdaptiveMention -Text 'przemyslaw.klys' -UserPrincipalName 'przemyslaw.klys@evotec.test' - - .EXAMPLE - New-AdaptiveMention -Text 'przemyslaw.klys' -UserPrincipalName 'przemyslaw.klys@evotec.test' -Name 'Przemysław Kłys' - - .EXAMPLE - New-AdaptiveCard -Uri $URI -VerticalContentAlignment center { - New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Dark - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Light - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Good - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1 Name' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1 Zenon Jaskuła' -Color Warning - } - } - New-AdaptiveMention -Text 'Zenon Jaskuła' -UserPrincipalName 'przemyslaw.klys@evotec.test' - New-AdaptiveMention -Text 'Name' -UserPrincipalName 'przemyslaw.klys@evotec.test' - } -Verbose -FullWidth - - .NOTES - More information here: https://github.com/EvotecIT/PSTeams/issues/17 - - #> - [cmdletBinding()] - param( - [parameter(Mandatory)][string] $Text, - [parameter(Mandatory)][string] $UserPrincipalName, - [string] $Name - ) - - $Mention = [ordered] @{ - "type" = "mention" - "text" = if ($Text -like "**") { $Text } else { "$Text" } - "mentioned" = @{ - id = $UserPrincipalName - name = $Name - } - } - if ($Name) { - $Mention.mentioned.name = $Name - } - $Mention -} \ No newline at end of file diff --git a/Public/New-AdaptiveRichTextBlock.ps1 b/Public/New-AdaptiveRichTextBlock.ps1 deleted file mode 100644 index 8cf60ae..0000000 --- a/Public/New-AdaptiveRichTextBlock.ps1 +++ /dev/null @@ -1,132 +0,0 @@ -function New-AdaptiveRichTextBlock { - <# - .SYNOPSIS - Defines an array of inlines, allowing for inline text formatting. - - .DESCRIPTION - Defines an array of inlines, allowing for inline text formatting. - - .PARAMETER Text - Text to display. - - .PARAMETER Color - Controls the color of text elements. - - .PARAMETER Subtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER Size - Controls size of text. - - .PARAMETER Weight - Controls the weight of text elements. - - .PARAMETER Highlight - Controls the hightlight of text elements - - .PARAMETER Italic - Controls italic of text elements - - .PARAMETER StrikeThrough - Controls strikethrough of text elements - - .PARAMETER FontType - Type of font to use for rendering - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Separator - Draw a separating line at the top of the element. - - .PARAMETER HorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER Height - Specifies the height of the element. - - .PARAMETER Id - A unique identifier associated with the item. - - .PARAMETER Hidden - If used this item will be removed from the visual tree. - - .EXAMPLE - New-AdaptiveRichTextBlock -Text 'This is the first inline.', 'We support colors,', 'both regular and subtle. ', 'Monospace too!' -Color Attention, Default, Warning -StrikeThrough $false, $true, $false -Size ExtraLarge, Default, Medium -Italic $false, $false, $true -Subtle $false, $true, $true - - .NOTES - General notes - #> - [cmdletBinding()] - param( - [string[]] $Text, - [ValidateSet("Accent", 'Default', 'Dark', 'Light', 'Good', 'Warning', 'Attention')][string[]] $Color = @(), - [bool[]] $Subtle = @(), - [alias('FontSize')][ValidateSet("Small", 'Default', "Medium", "Large", "ExtraLarge")][string[]] $Size = @(), - [alias('FontWeight')][ValidateSet("Lighter", 'Default', "Bolder")][string[]] $Weight = @(), - [bool[]] $Highlight = @(), - [bool[]] $Italic = @(), - [bool[]] $StrikeThrough = @(), - [ValidateSet('Default', 'Monospace')][string[]] $FontType = @(), - # Layout Start - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [switch] $Separator, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [ValidateSet('Stretch', 'Automatic')][string] $Height, - # Layout End - [string] $Id, - [switch] $Hidden - ) - - [Array] $Inlines = for ($a = 0; $a -lt $Text.Count; $a++) { - $TextRun = [ordered] @{ - type = 'TextRun' - text = $Text[$a] - } - if ($Color[$a]) { - $TextRun['color'] = $Color[$a] - } - if ($Subtle[$a]) { - $TextRun['subtle'] = $Subtle[$a] - } - if ($Size[$a]) { - $TextRun['size'] = $Size[$a] - } - if ($Weight[$a]) { - $TextRun['weight'] = $Weight[$a] - } - if ($Highlight[$a]) { - $TextRun['highlight'] = $Highlight[$a] - } - if ($Italic[$a]) { - $TextRun['italic'] = $Italic[$a] - } - if ($StrikeThrough[$a]) { - $TextRun['strikethrough'] = $StrikeThrough[$a] - } - if ($FontType[$a]) { - $TextRun['fontType'] = $FontType[$a] - } - $TextRun - } - $TeamObject = [ordered]@{ - type = "RichTextBlock" - id = $Id - inlines = $Inlines - # Start Layout - horizontalAlignment = $HorizontalAlignment - height = $Height - spacing = $Spacing - # End Layout - } - # Start Layout - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - # End Layout - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - Remove-EmptyValue -Hashtable $TeamObject - $TeamObject -} \ No newline at end of file diff --git a/Public/New-AdaptiveTable.ps1 b/Public/New-AdaptiveTable.ps1 deleted file mode 100644 index 32fa6fc..0000000 --- a/Public/New-AdaptiveTable.ps1 +++ /dev/null @@ -1,267 +0,0 @@ -function New-AdaptiveTable { - <# - .SYNOPSIS - Simplifies process of creating an adaptive table by working with PowerShell objects - - .DESCRIPTION - Simplifies process of creating an adaptive table by working with PowerShell objects - - .PARAMETER DataTable - Provide a data table to be converted to an adaptive table - - .PARAMETER Width - Controls the horizontal size of the element. - - .PARAMETER HeaderColor - Provide a color to be used for the header row of the table. By default, the header row is set to 'Accent' - - .PARAMETER HeaderWeight - Provide a weight to be used for the header row of the table. By default, the header row is set to 'Bolder' - - .PARAMETER HeaderSubtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER HeaderMaximumLines - Specifies the maximum number of lines to display. - - .PARAMETER HeaderFontType - Type of font to use for rendering - - .PARAMETER HeaderHorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER HeaderSubtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER HeaderMaximumLines - Specifies the maximum number of lines to display. - - .PARAMETER HeaderSize - Controls size of text. - - .PARAMETER HeaderHighlight - Controls the hightlight of text elements - - .PARAMETER HeaderStrikeThrough - Controls strikethrough of text elements - - .PARAMETER HeaderItalic - Controls italic of text elements - - .PARAMETER HeaderWrap - Allow text to wrap. Otherwise, text is clipped. - - .PARAMETER HeaderHeight - Specifies the height of the element. - - .PARAMETER HeaderSpacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Subtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER MaximumLines - Specifies the maximum number of lines to display. - - .PARAMETER Color - Controls the color of TextBlock elements. - - .PARAMETER FontType - Type of font to use for rendering - - .PARAMETER HorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER Subtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER MaximumLines - Specifies the maximum number of lines to display. - - .PARAMETER Size - Controls size of text. - - .PARAMETER Weight - Controls the weight of TextBlock elements. - - .PARAMETER Highlight - Controls the hightlight of text elements - - .PARAMETER StrikeThrough - Controls strikethrough of text elements - - .PARAMETER Italic - Controls italic of text elements - - .PARAMETER Wrap - Allow text to wrap. Otherwise, text is clipped. - - .PARAMETER Height - Specifies the height of the element. - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER DictionaryAsCustomObject - Forces display of Dictionary the same way as a custom object. By default, the Dictionary is displayed the way you see with Format-Table - - .EXAMPLE - $Card = New-AdaptiveCard { - New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Table usage with PSCustomObject' -Separator -Wrap - - New-AdaptiveTable -DataTable $Objects - - New-AdaptiveTextBlock -Size 'Medium' -Weight Bolder -Text 'Table usage with OrderedDictionary' -Separator -Wrap - - New-AdaptiveTable -DataTable $ObjectsHashes - } -Uri $Env:TEAMSPESTERID - - .NOTES - General notes - #> - [CmdletBinding()] - param( - [Array] $DataTable, - [ValidateSet('Auto', 'Stretch')][string] $Width = 'Stretch', - [ValidateSet("Accent", 'Default', 'Dark', 'Light', 'Good', 'Warning', 'Attention')][string] $HeaderColor = 'Accent', - [alias('HeaderFontWeight')][ValidateSet("Lighter", 'Default', "Bolder")][string] $HeaderWeight = 'Bolder', - [alias('HeaderFontSize')][ValidateSet("Small", 'Default', "Medium", "Large", "ExtraLarge")][string] $HeaderSize, - [switch] $HeaderHighlight, - [switch] $HeaderItalic, - [switch] $HeaderStrikeThrough, - [ValidateSet('Default', 'Monospace')][string] $HeaderFontType, - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $HeaderSpacing, - [ValidateSet("Left", "Center", 'Right')][string] $HeaderHorizontalAlignment, - [alias('HeaderBlockElementHeight')][ValidateSet('Stretch', 'Automatic')][string] $HeaderHeight, - [switch] $HeaderSubtle, - [int] $HeaderMaximumLines, - - [alias('FontWeight')][ValidateSet("Lighter", 'Default', "Bolder")][string] $Weight, - [alias('FontSize')][ValidateSet("Small", 'Default', "Medium", "Large", "ExtraLarge")][string] $Size, - [ValidateSet("Accent", 'Default', 'Dark', 'Light', 'Good', 'Warning', 'Attention')][string] $Color, - [bool] $Highlight, - [bool] $Italic, - [bool] $StrikeThrough, - [ValidateSet('Default', 'Monospace')][string[]] $FontType, - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [switch] $Wrap, - [alias('BlockElementHeight')][ValidateSet('Stretch', 'Automatic')][string] $Height, - [switch] $Subtle, - [int] $MaximumLines, - - [alias('HashTableAsCustomObject')][switch] $DictionaryAsCustomObject, - [switch] $DisableHeaderColumnSeparators, - [switch] $DisableRowSeparators, - [switch] $DisableColumnSeparators - ) - # We cleanup things before we start - # This is also required because it seems to be working badly - # in terms of availability of properties such as HorizontalAlignment once it runs few of variables - # it starts bleeding thru the rest of the code overwritting values - $ContentAdaptiveTextBlockSplat = @{ - Weight = $Weight - Color = $Color - Wrap = $Wrap.IsPresent - Size = $Size - Highlight = $Highlight.IsPresent - Italic = $Italic.IsPresent - StrikeThrough = $StrikeThrough.IsPresent - FontType = $FontType - Spacing = $Spacing - HorizontalAlignment = $HorizontalAlignment - Height = $Height - MaximumLines = $MaximumLines - } - Remove-EmptyValue -Hashtable $ContentAdaptiveTextBlockSplat - - $HeaderAdaptiveTextBlockSplat = @{ - Weight = $HeaderWeight - Color = $HeaderColor - Wrap = $HeaderWrap.IsPresent - Size = $HeaderSize - Highlight = $HeaderHighlight.IsPresent - Italic = $HeaderItalic.IsPresent - StrikeThrough = $HeaderStrikeThrough.IsPresent - FontType = $HeaderFontType - Spacing = $HeaderSpacing - HorizontalAlignment = $HeaderHorizontalAlignment - MaximumLines = $HeaderMaximumLines - Height = $HeaderHeight - } - Remove-EmptyValue -Hashtable $HeaderAdaptiveTextBlockSplat - - if ($DataTable[0] -is [System.Collections.IDictionary]) { - # Process hashtables and dictionaries - if ($DictionaryAsCustomObject) { - # process it the same way as a custom object - #Header - New-AdaptiveColumnSet { - for ($i = 0; $i -lt $DataTable[0].Keys.Count; $i++) { - New-AdaptiveColumn { - $HeaderText = @($DataTable[0].Keys)[$i] - New-AdaptiveTextBlock @HeaderAdaptiveTextBlockSplat -Text $HeaderText - } -Width $Width -Separator:(-not $DisableHeaderColumnSeparators.IsPresent) - } - } - #Data - for ($j = 0; $j -lt $DataTable.Count; $j++) { - - New-AdaptiveColumnSet { - for ($i = 0; $i -lt $DataTable[0].Keys.Count; $i++) { - New-AdaptiveColumn { - $Value = @($DataTable[$j].Values)[$i] - New-AdaptiveTextBlock @ContentAdaptiveTextBlockSplat -Text $Value - } -Width $Width -Separator:(-not $DisableColumnSeparators.IsPresent) - } - } -Separator:(-not $DisableRowSeparators.IsPresent) - } - - } else { - # Process as standard hashtable - # Header - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock @HeaderAdaptiveTextBlockSplat -Text 'Name' - } -Width $Width -Separator:(-not $DisableHeaderColumnSeparators.IsPresent) - New-AdaptiveColumn { - New-AdaptiveTextBlock @HeaderAdaptiveTextBlockSplat -Text 'Value' - } -Width $Width -Separator:(-not $DisableHeaderColumnSeparators.IsPresent) - } - # Data - foreach ($Data in $DataTable) { - foreach ($Key in $Data.Keys) { - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock @ContentAdaptiveTextBlockSplat -Text $Key -Separator - } -Width $Width -Separator:(-not $DisableColumnSeparators.IsPresent) - New-AdaptiveColumn { - New-AdaptiveTextBlock @ContentAdaptiveTextBlockSplat -Text $Data.$Key -Separator - } -Width $Width -Separator:(-not $DisableColumnSeparators.IsPresent) - } -Separator:(-not $DisableRowSeparators.IsPresent) - } - } - } - } else { - #Header - New-AdaptiveColumnSet { - for ($Column = 0; $Column -lt $DataTable[0].PSObject.Properties.Name.Count; $Column++) { - New-AdaptiveColumn { - $HeaderText = $DataTable[0].PSObject.Properties.Name[$Column] - New-AdaptiveTextBlock @HeaderAdaptiveTextBlockSplat -Text $HeaderText - } -Width $Width -Separator:(-not $DisableHeaderColumnSeparators.IsPresent) - } - } - #Data - for ($Row = 0; $Row -lt $DataTable.Count; $Row++) { - New-AdaptiveColumnSet { - for ($Column = 0; $Column -lt $DataTable[$Row].PSObject.Properties.Name.Count; $Column++) { - New-AdaptiveColumn { - $Value = $DataTable[$Row].PSObject.Properties.Value[$Column] - New-AdaptiveTextBlock @ContentAdaptiveTextBlockSplat -Text $Value - } -Width $Width -Separator:(-not $DisableColumnSeparators.IsPresent) - } - } -Separator:(-not $DisableRowSeparators.IsPresent) - } - } -} \ No newline at end of file diff --git a/Public/New-AdaptiveTextBlock.ps1 b/Public/New-AdaptiveTextBlock.ps1 deleted file mode 100644 index 1ca7d24..0000000 --- a/Public/New-AdaptiveTextBlock.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -function New-AdaptiveTextBlock { - <# - .SYNOPSIS - Displays text, allowing control over font sizes, weight, and color. - - .DESCRIPTION - Displays text, allowing control over font sizes, weight, and color. - - .PARAMETER Text - Text to display. A subset of markdown is supported (https://aka.ms/ACTextFeatures) - - .PARAMETER Color - Controls the color of TextBlock elements. - - .PARAMETER FontType - Type of font to use for rendering - - .PARAMETER HorizontalAlignment - Controls the horizontal text alignment. - - .PARAMETER Subtle - Displays text slightly toned down to appear less prominent. - - .PARAMETER MaximumLines - Specifies the maximum number of lines to display. - - .PARAMETER Size - Controls size of text. - - .PARAMETER Weight - Controls the weight of TextBlock elements. - - .PARAMETER Highlight - Controls the hightlight of text elements - - .PARAMETER StrikeThrough - Controls strikethrough of text elements - - .PARAMETER Italic - Controls italic of text elements - - .PARAMETER Wrap - Allow text to wrap. Otherwise, text is clipped. - - .PARAMETER Height - Specifies the height of the element. - - .PARAMETER Separator - Draw a separating line at the top of the element. - - .PARAMETER Spacing - Controls the amount of spacing between this element and the preceding element. - - .PARAMETER Id - A unique identifier associated with the item. - - .PARAMETER Hidden - If used this item will be removed from the visual tree. - - .EXAMPLE - New-AdaptiveCard -Uri $Env:TEAMSPESTERID -VerticalContentAlignment center { - New-AdaptiveTextBlock -Size ExtraLarge -Weight Bolder -Text 'Test' -Color Attention -HorizontalAlignment Center - New-AdaptiveColumnSet { - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Dark - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Light - } - New-AdaptiveColumn { - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Warning - New-AdaptiveTextBlock -Size 'Medium' -Text 'Test Card Title 1' -Color Good - } - } - } -SelectAction Action.OpenUrl -SelectActionUrl 'https://evotec.xyz' -Verbose - - .NOTES - General notes - #> - [cmdletBinding()] - param( - [string] $Text, - [ValidateSet("Accent", 'Default', 'Dark', 'Light', 'Good', 'Warning', 'Attention')][string] $Color, - [ValidateSet('Default', 'Monospace')][string] $FontType, - [ValidateSet("Left", "Center", 'Right')][string] $HorizontalAlignment, - [switch] $Subtle, - [int] $MaximumLines, - [alias('FontSize')][ValidateSet("Small", 'Default', "Medium", "Large", "ExtraLarge")][string] $Size, - [alias('FontWeight')][ValidateSet("Lighter", 'Default', "Bolder")][string] $Weight, - [switch] $Highlight, - [switch] $Italic, - [switch] $StrikeThrough, - [switch] $Wrap, - [alias('BlockElementHeight')][ValidateSet('Stretch', 'Automatic')][string] $Height, - [switch] $Separator, - [ValidateSet('None', 'Small', 'Default', 'Medium', 'Large', 'ExtraLarge', 'Padding')][string] $Spacing, - [string] $Id, - [switch] $Hidden - ) - $TeamObject = [ordered]@{ - type = "TextBlock" - # the intent behind this is to allow for empty textblocks to be created - # if there is no text, the block is never added which causes all sort of issues - # this is a workaround for that - text = if ($Text -eq '') { "$([char]0x200F)" } else { $Text } - id = $Id - spacing = $Spacing - horizontalAlignment = $HorizontalAlignment - size = $Size - weight = $Weight - color = $Color - height = $Height - fontType = $FontType - highlight = $Highlight - italic = $Italic - strikeThrough = $StrikeThrough - } - if ($MaximumLines) { - $TeamObject['maxLines'] = $MaximumLines - } - if ($Separator) { - $TeamObject['separator'] = $Separator.IsPresent - } - if ($Wrap) { - $TeamObject['wrap'] = $Wrap.IsPresent - } - if ($Subtle) { - $TeamObject['isSubtle'] = $true - } - if ($Hidden) { - $TeamObject['isVisible'] = $false - } - Remove-EmptyValue -Hashtable $TeamObject -ExcludeParameter 'text' - $TeamObject -} - diff --git a/Public/New-CardList.ps1 b/Public/New-CardList.ps1 deleted file mode 100644 index ed2f09e..0000000 --- a/Public/New-CardList.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -function New-CardList { - [cmdletBinding()] - param( - [Parameter(Mandatory)][scriptblock] $Content, - [string] $Title, - [string] $Uri - ) - if ($Content) { - $Buttons = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $Items = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $ExecutedContent = & $Content - foreach ($E in $ExecutedContent) { - if ($E.Value) { - if ($Buttons.Count -lt 6) { - $Buttons.Add($E) - } else { - Write-Warning "New-CardList - List Cards support only up to 6 buttons." - } - } else { - $Items.Add($E) - } - } - - $Wrapper = @{ - contentType = "application/vnd.microsoft.teams.card.list" - content = @{ - title = $Title - items = @( - $Items - ) - buttons = @( - $Buttons - ) - } - } - } - $Body = $Wrapper | ConvertTo-Json -Depth 20 - if ($Uri) { - Send-TeamsMessageBody -Uri $URI -Body $Body -Wrap - } else { - $Body - } -} \ No newline at end of file diff --git a/Public/New-CardListButton.ps1 b/Public/New-CardListButton.ps1 deleted file mode 100644 index e2ef09b..0000000 --- a/Public/New-CardListButton.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -function New-CardListButton { - [alias('New-HeroButton', 'New-ThumbnailButton')] - [cmdletBinding()] - param( - [ValidateSet('imBack', 'openUrl', 'file')][string] $Type, - [string] $Title, - [string] $Value, - [string] $Image - ) - if ($Image) { - Write-Warning "Using Image for Buttons while technically supported by Teams, it's not supported by Teams Connectors. Leaving this in place just in case it starts working" - } - $TeamsObject = [ordered] @{ - "type" = $Type - "title" = $Title - "value" = $Value - "image" = $Image - } - Remove-EmptyValue -Hashtable $TeamsObject - $TeamsObject -} \ No newline at end of file diff --git a/Public/New-CardListItem.ps1 b/Public/New-CardListItem.ps1 deleted file mode 100644 index b4cfc17..0000000 --- a/Public/New-CardListItem.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -function New-CardListItem { - [cmdletBinding()] - param( - [parameter(Mandatory)][ValidateSet('file', 'resultItem', 'section', 'person')][string] $Type, - [string] $Icon, - [string] $Title, - [string] $SubTitle, - [ValidateSet('whois', 'editOnline')][string] $TapAction, - [ValidateSet('imBack', 'openUrl', 'file')][string] $TapType, - [string] $TapValue - ) - $TeamsObject = [ordered] @{ - type = $Type - id = if ($TapAction) { $TapValue } else { '' } - title = $Title - subtitle = $SubTitle - icon = $Icon - tap = @{ - type = $TapType - value = "$TapAction $TapValue".Trim() - } - } - Remove-EmptyValue -Hashtable $TeamsObject -Recursive -Rerun 2 - $TeamsObject -} \ No newline at end of file diff --git a/Public/New-HeroCard.ps1 b/Public/New-HeroCard.ps1 deleted file mode 100644 index f252901..0000000 --- a/Public/New-HeroCard.ps1 +++ /dev/null @@ -1,84 +0,0 @@ -function New-HeroCard { - <# - .SYNOPSIS - Provides a quick and easy way to build a Hero Card and send it thru incoming webhook to Microsoft Teams. - - .DESCRIPTION - Provides a quick and easy way to build a Hero Card and send it thru incoming webhook to Microsoft Teams. - - .PARAMETER Content - ScriptBlock to define the content of HeroCard - - .PARAMETER Title - Title of the hero card - - .PARAMETER SubTitle - Subtitle of the hero card - - .PARAMETER Text - Text to display within hero card - - .PARAMETER Uri - URI/URL of Incoming Webhook generated in Microsoft Teams - - .EXAMPLE - New-HeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text "The Seattle Center Monorail is an elevated train line between Seattle Center (near the Space Needle) and downtown Seattle. It was built for the 1962 World's Fair. Its original two trains, completed in 1961, are still in service." { - New-HeroImage -Url 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Seattle_monorail01_2008-02-25.jpg/1024px-Seattle_monorail01_2008-02-25.jpg' - New-HeroButton -Type openUrl -Title 'Official website' -Value 'https://www.seattlemonorail.com' - New-HeroButton -Type openUrl -Title 'Wikipeda page' -Value 'https://www.seattlemonorail.com' - New-HeroButton -Type imBack -Title 'Evotec page' -Value 'https://www.evotec.xyz' - } -Uri $URI - - .NOTES - General notes - #> - [cmdletBinding()] - param( - [Parameter(Mandatory)][scriptblock] $Content, - [string] $Title, - [string] $SubTitle, - [string] $Text, - [string] $Uri - ) - if ($Content) { - $Buttons = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $Images = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $ExecutedContent = & $Content - foreach ($E in $ExecutedContent) { - if ($E.Value) { - if ($Buttons.Count -lt 3) { - $Buttons.Add($E) - } else { - Write-Warning "New-HeroCard - Herd Card support only up to 3 buttons." - } - } else { - if ($Images.Count -lt 2) { - $Images.Add($E) - } else { - Write-Warning "New-HeroCard - Herd Card support only 1 image." - } - } - } - - $Wrapper = @{ - contentType = "application/vnd.microsoft.card.hero" - content = @{ - title = $Title - subTitle = $SubTitle - text = $Text - images = @( - $Images - ) - buttons = @( - $Buttons - ) - } - } - } - $Body = $Wrapper | ConvertTo-Json -Depth 20 - if ($Uri) { - Send-TeamsMessageBody -Uri $URI -Body $Body -Wrap - } else { - $Body - } -} \ No newline at end of file diff --git a/Public/New-TeamsActivityImage.ps1 b/Public/New-TeamsActivityImage.ps1 deleted file mode 100644 index 43cba6c..0000000 --- a/Public/New-TeamsActivityImage.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -function New-TeamsActivityImage { - <# - .SYNOPSIS - Adds ability to add an image to an activity within Office 365 Connector Card - - .DESCRIPTION - Adds ability to add an image to an activity within Office 365 Connector Card - - .PARAMETER Image - Choose one of built-in images to display. Options are: 'Add', 'Alert', 'Cancel', 'Check', 'Disable', 'Download', 'Info', 'Minus', 'Question', 'Reload', 'None' - - .PARAMETER Link - Provide a link to image to display in the card. - - .PARAMETER Path - Provide a path to image to display in the card. JPG and PNG files are supported. - - .EXAMPLE - Send-TeamsMessage -URI $TeamsID -MessageTitle 'PSTeams - Pester Test' -MessageText "This text will show up" -Color DodgerBlue { - New-TeamsSection { - New-TeamsActivityTitle -Title "**PSTeams**" - New-TeamsActivitySubtitle -Subtitle "@PSTeams - $CurrentDate" - New-TeamsActivityImage -Image Add - New-TeamsActivityText -Text "This message proves PSTeams Pester test passed properly." - New-TeamsFact -Name 'PS Version' -Value "**$($PSVersionTable.PSVersion)**" - New-TeamsFact -Name 'PS Edition' -Value "**$($PSVersionTable.PSEdition)**" - New-TeamsFact -Name 'OS' -Value "**$($PSVersionTable.OS)**" - New-TeamsButton -Name 'Visit English Evotec Website' -Link "https://evotec.xyz" - } - } -Verbose - - .NOTES - General notes - #> - [CmdletBinding(DefaultParameterSetName = 'Link')] - [alias('ActivityImageLink', 'TeamsActivityImageLink', 'New-TeamsActivityImageLink', 'ActivityImage', 'TeamsActivityImage')] - param( - [Parameter(ParameterSetName = 'Image')][string][ValidateSet('Add', 'Alert', 'Cancel', 'Check', 'Disable', 'Download', 'Info', 'Minus', 'Question', 'Reload', 'None')] $Image, - [Parameter(ParameterSetName = 'Link')][string] $Link, - - [Parameter(ParameterSetName = 'Path')] - [ValidateScript({ - if (-not ($_ | Test-Path)) { - throw "Path is inaccessible or does not exist" - } - if (-not ($_ | Test-Path -PathType Leaf) -or ($_ -notmatch "(\.jpg|\.png)")) { - throw "Path is not a file or file extension is not supported" - } - return $true - })] - [System.IO.FileInfo] $Path - ) - if ($Path) { - $FilePath = [System.IO.Path]::GetDirectoryName($Path) - $FileBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Path) - $FileExtension = [System.IO.Path]::GetExtension($Path) - @{ - ActivityImageLink = Get-Image -PathToImages $FilePath -FileName $FileBaseName -FileExtension $FileExtension -Verbose - type = 'ActivityImage' - } - } elseif ($Image) { - if ($Image -ne 'None') { - $StoredImages = [IO.Path]::Combine($PSScriptRoot, '..', 'Images') - @{ - ActivityImageLink = Get-Image -PathToImages $StoredImages -FileName $Image -FileExtension '.jpg' # -Verbose - type = 'ActivityImage' - } - } - } else { - @{ - ActivityImageLink = $Link - Type = 'ActivityImageLink' - } - } -} diff --git a/Public/New-TeamsActivitySubtitle.ps1 b/Public/New-TeamsActivitySubtitle.ps1 deleted file mode 100644 index 6e4b7a9..0000000 --- a/Public/New-TeamsActivitySubtitle.ps1 +++ /dev/null @@ -1,11 +0,0 @@ -function New-TeamsActivitySubtitle { - [CmdletBinding()] - [alias('ActivitySubtitle', 'TeamsActivitySubtitle')] - param( - [string] $Subtitle - ) - @{ - ActivitySubtitle = $Subtitle - Type = 'ActivitySubtitle' - } -} \ No newline at end of file diff --git a/Public/New-TeamsActivityTItle.ps1 b/Public/New-TeamsActivityTItle.ps1 deleted file mode 100644 index 641e3df..0000000 --- a/Public/New-TeamsActivityTItle.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -function New-TeamsActivityTitle { - [CmdletBinding()] - [alias('ActivityTitle', 'TeamsActivityTitle')] - param( - [string] $Title - ) - @{ - ActivityTitle = $Title - Type = 'ActivityTitle' - } - -} \ No newline at end of file diff --git a/Public/New-TeamsActivityText.ps1 b/Public/New-TeamsActivityText.ps1 deleted file mode 100644 index 05cf510..0000000 --- a/Public/New-TeamsActivityText.ps1 +++ /dev/null @@ -1,11 +0,0 @@ -function New-TeamsActivityText { - [CmdletBinding()] - [alias('ActivityText','TeamsActivityText')] - param( - [string] $Text - ) - @{ - ActivityText = $Text - Type = 'ActivityText' - } -} \ No newline at end of file diff --git a/Public/New-TeamsBigImage.ps1 b/Public/New-TeamsBigImage.ps1 deleted file mode 100644 index f66265d..0000000 --- a/Public/New-TeamsBigImage.ps1 +++ /dev/null @@ -1,14 +0,0 @@ -function New-TeamsBigImage { - [alias('TeamsBigImage')] - [CmdletBinding()] - param( - [alias('Url', 'Uri')] $Link, - [string] $AlternativeText = 'Alternative Text' - ) - if ($Link) { - [ordered] @{ - image = "![$AlternativeText]($Link)" - type = 'HeroImageWorkaround' - } - } -} \ No newline at end of file diff --git a/Public/New-TeamsButton.ps1 b/Public/New-TeamsButton.ps1 deleted file mode 100644 index da0c9c7..0000000 --- a/Public/New-TeamsButton.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -function New-TeamsButton { - [alias('TeamsButton')] - [CmdletBinding()] - param ( - [alias('ButtonName')][Parameter(Mandatory)][ValidateNotNull()][ValidateNotNullOrEmpty()][string] $Name, - [alias('TargetUri', 'Uri', 'Url')][Parameter(Mandatory)][ValidateNotNull()][ValidateNotNullOrEmpty()][string] $Link, - [alias('ButtonType')][string][ValidateSet('ViewAction', 'TextInput', 'DateInput', 'HttpPost', 'OpenUri')] $Type = 'ViewAction' - ) - if ($Type -eq 'ViewAction') { - $Button = [ordered] @{ - '@context' = 'http://schema.org' - '@type' = 'ViewAction' - name = "$Name" - target = @("$Link") - type = 'button' # this is only needed for module to process this correctly. JSON doesn't care - } - } elseif ($Type -eq 'TextInput') { - $Button = [ordered] @{ - #'@context' = 'http://schema.org' - '@type' = 'ActionCard' - 'Name' = $Name - 'Inputs' = @( - @{ - '@type' = 'TextInput' - 'id' = 'Comment' - 'isMultiLine' = $true - 'title' = 'Enter Your Text Input Here' - } - ) - actions = @( - @{ - '@type' = 'HttpPOST' - 'Name' = 'OK' - 'target' = $Link - } - ) - type = 'button' # this is only needed for module to process this correctly. JSON doesn't care - } - } elseif ($Type -eq 'DateInput') { - $Button = [ordered] @{ - '@type' = 'ActionCard' - 'Name' = $Name - 'Inputs' = @( - @{ - '@type' = 'DateInput' - 'id' = 'dueDate' - } - ) - actions = @( - @{ - '@type' = 'HttpPOST' - 'Name' = 'OK' - 'target' = $Link - } - ) - type = 'button' # this is only needed for module to process this correctly. JSON doesn't care - } - } elseif ($Type -eq 'HttpPost') { - $Button = [ordered] @{ - 'name' = $Name - '@type' = 'HttpPOST' - 'Target' = $Link - type = 'button' # this is only needed for module to process this correctly. JSON doesn't care - } - } elseif ($Type -eq 'OpenUri') { - $Button = [ordered] @{ - 'name' = $Name - '@type' = 'OpenURI' - 'Targets' = @( - @{ - 'os' = 'default' - 'uri' = $Link - } - ) - type = 'button' # this is only needed for module to process this correctly. JSON doesn't care - } - } - return $Button -} \ No newline at end of file diff --git a/Public/New-TeamsFact.ps1 b/Public/New-TeamsFact.ps1 deleted file mode 100644 index a9cd9d9..0000000 --- a/Public/New-TeamsFact.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -function New-TeamsFact { - [alias('TeamsFact')] - [CmdletBinding()] - param ( - [string] $Name, - [string] $Value - ) - $Fact = [ordered] @{ - name = "$Name" - value = "$Value" - type = 'fact' # this is only needed for module to process this correctly. JSON doesn't care - #wrap = $false - } - return $Fact -} \ No newline at end of file diff --git a/Public/New-TeamsImage.ps1 b/Public/New-TeamsImage.ps1 deleted file mode 100644 index f42a79d..0000000 --- a/Public/New-TeamsImage.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -function New-TeamsImage { - [alias('TeamsImage')] - [CmdletBinding()] - param( - [alias('Url', 'Uri')] $Link - ) - if ($Link) { - [ordered] @{ - image = $Link - type = 'image' - } - } -} \ No newline at end of file diff --git a/Public/New-TeamsList.ps1 b/Public/New-TeamsList.ps1 deleted file mode 100644 index 9fda436..0000000 --- a/Public/New-TeamsList.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -function New-TeamsList { - [alias('TeamsList')] - [CmdletBinding()] - param( - [scriptblock] $List, - [string] $Name - ) - - if ($List) { - $Output = & $List - [Array] $Fact = foreach ($_ in $Output) { - if ($_.Numbered) { - $Type = '1. ' - } else { - $Type = "- " - } - if ($_.Type -eq 'ListItem') { - "`t" * $_.Level + $Type + $_.Text - } - } - [string] $Value = $Fact -join "`r" #[System.Environment]::NewLine - - New-TeamsFact -Name $Name -Value $Value - } -} \ No newline at end of file diff --git a/Public/New-TeamsListItem.ps1 b/Public/New-TeamsListItem.ps1 deleted file mode 100644 index 8bdad91..0000000 --- a/Public/New-TeamsListItem.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -function New-TeamsListItem { - [alias('TeamsListItem')] - [CmdletBinding()] - param( - [string] $Text, - [int] $Level, - [switch] $Numbered - ) - [ordered] @{ - Text = $Text - Level = $Level - Numbered = $Numbered.IsPresent - Type = 'ListItem' - } -} \ No newline at end of file diff --git a/Public/New-TeamsSection.ps1 b/Public/New-TeamsSection.ps1 deleted file mode 100644 index 8a92a27..0000000 --- a/Public/New-TeamsSection.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -function New-TeamsSection { - [alias('TeamsSection')] - [CmdletBinding()] - param ( - [scriptblock] $SectionInput, - [string] $Title, - [string] $ActivityTitle, - [string] $ActivitySubtitle , - [string] $ActivityImageLink, - [string][ValidateSet('Alert', 'Cancel', 'Disable', 'Download', 'Minus', 'Check', 'Add', 'None')] $ActivityImage = 'None', - - [ValidateScript({ - if (-not ($_ | Test-Path)) { - throw "ActivityImagePath is inaccessible or does not exist" - } - if (-not ($_ | Test-Path -PathType Leaf) -or ($_ -notmatch "(\.jpg|\.png)")) { - throw "ActivityImagePath is not a file or file extension is not supported" - } - return $true - })] - [System.IO.FileInfo] $ActivityImagePath, - - [string] $ActivityText, - [string] $Text, - [System.Collections.IDictionary[]]$ActivityDetails, - [System.Collections.IDictionary[]]$Buttons, - [switch] $StartGroup - ) - - if ($ActivityImagePath) { # ActivityImagePath takes precedence over ActivityImage - $FilePath = [System.IO.Path]::GetDirectoryName($ActivityImagePath) - $FileBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ActivityImagePath) - $FileExtension = [System.IO.Path]::GetExtension($ActivityImagePath) - $ActivityImageLink = Get-Image -PathToImages $FilePath -FileName $FileBaseName -FileExtension $FileExtension # -Verbose - } - elseif ($ActivityImage -ne 'None') { - $StoredImages = [IO.Path]::Combine($PSScriptRoot, '..', 'Images') - $ActivityImageLink = Get-Image -PathToImages $StoredImages -FileName $ActivityImage -FileExtension '.jpg' # -Verbose - } - - $ButtonsList = [System.Collections.Generic.List[System.Collections.IDictionary]]::new() - $FactList = [System.Collections.Generic.List[System.Collections.IDictionary]]::new() - $ImagesList = [System.Collections.Generic.List[System.Collections.IDictionary]]::new() - $ImageHeroList = [System.Collections.Generic.List[System.Collections.IDictionary]]::new() - - if ($SectionInput) { - $SectionOutput = & $SectionInput - foreach ($_ in $SectionOutput) { - if ($_.Type -eq 'button') { - $_.Remove('Type') - $ButtonsList.Add($_) - } elseif ($_.Type -eq 'fact') { - $_.Remove('Type') - $FactList.Add($_) - } elseif ($_.Type -eq 'image') { - $_.Remove('Type') - $ImagesList.Add($_) - } elseif ($_.Type -eq 'HeroImageWorkaround') { - $ImageHeroList.Add($_) - } elseif ($_.Type -eq 'ActivityTitle') { - $ActivityTitle = $_.ActivityTitle - } elseif ($_.Type -eq 'ActivitySubtitle') { - $ActivitySubtitle = $_.ActivitySubtitle - } elseif ($_.Type -eq 'ActivityImageLink') { - $ActivityImageLink = $_.ActivityImageLink - } elseif ($_.Type -eq 'ActivityText') { - $ActivityText = $_.ActivityText - } elseif ($_.Type -eq 'ActivityImage') { - $ActivityImageLink = $_.ActivityImageLink - } - } - } - - $Section = [ordered] @{ } - if ($Title) { - $Section.title = $Title - } - if ($ActivityTitle) { - $Section.activityTitle = "$($ActivityTitle)" - } - if ($ActivitySubtitle) { - $Section.activitySubtitle = "$($ActivitySubtitle)" - } - if ($ActivityImageLink) { - $Section.activityImage = "$($ActivityImageLink)" - } - if ($ActivityText) { - $Section.activityText = "$($ActivityText)" - } - - - # $section.heroImage = @{ image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Seattle_monorail01_2008-02-25.jpg/1024px-Seattle_monorail01_2008-02-25.jpg" } - - if ($Text -or $ImageHeroList.Count -gt 0) { - if ($ImageHeroList.Count -gt 0) { - [string] $TextBundle = @( - foreach ($_ in $ImageHeroList) { - $_.Image - } - if ($Text) { - $Text - } - ) - } else { - [string] $TextBundle = $Text - } - $section.text = $TextBundle - } - if ($ImagesList.Count -gt 0) { - $section.images = @( $ImagesList ) - } - if ($StartGroup) { - $Section.startGroup = $startGroup.IsPresent - } - if ($null -ne $ActivityDetails -or $FactList.Count -gt 0) { - $Section.facts = @( - if ($SectionInput) { - $FactList - } else { - $ActivityDetails - } - ) - } - if ($null -ne $Buttons -or $ButtonsList.Count -gt 0) { - $Section.potentialAction = @( - if ($SectionInput) { - $ButtonsList - } else { - $Buttons - } - ) - } - return $Section -} diff --git a/Public/New-ThumbnailCard.ps1 b/Public/New-ThumbnailCard.ps1 deleted file mode 100644 index b04eac9..0000000 --- a/Public/New-ThumbnailCard.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -function New-ThumbnailCard { - [cmdletBinding()] - param( - [Parameter(Mandatory)][scriptblock] $Content, - [string] $Title, - [string] $SubTitle, - [string] $Text, - [string] $Uri - ) - if ($Content) { - $Buttons = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $Images = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - $ExecutedContent = & $Content - foreach ($E in $ExecutedContent) { - if ($E.Value) { - if ($Buttons.Count -lt 6) { - $Buttons.Add($E) - } else { - Write-Warning "New-ThumbnailCard - Thumbnail Card support only up to 6 buttons." - } - } else { - if ($Images.Count -lt 1) { - $Images.Add($E) - } else { - Write-Warning "New-ThumbnailCard - Thumbnail Card support only 1 image." - } - } - } - - $Wrapper = [ordered]@{ - contentType = "application/vnd.microsoft.card.thumbnail" - content = [ordered]@{ - title = $Title - subTitle = $SubTitle - text = $Text - images = @( - $Images - ) - buttons = @( - $Buttons - ) - } - } - } - $Body = $Wrapper | ConvertTo-Json -Depth 20 - if ($Uri) { - Send-TeamsMessageBody -Uri $URI -Body $Body -Wrap - } else { - $Body - } -} \ No newline at end of file diff --git a/Public/Send-TeamsMessage.ps1 b/Public/Send-TeamsMessage.ps1 deleted file mode 100644 index abbb13c..0000000 --- a/Public/Send-TeamsMessage.ps1 +++ /dev/null @@ -1,69 +0,0 @@ -function Send-TeamsMessage { - [alias('TeamsMessage')] - [CmdletBinding()] - Param ( - [scriptblock] $SectionsInput, - [alias("TeamsID", 'Url')][Parameter(Mandatory)][string]$Uri, - [string]$MessageTitle, - [string]$MessageText, - [string]$MessageSummary, - [string]$Color, - [switch]$HideOriginalBody, - [Uri]$Proxy, - [System.Collections.IDictionary[]]$Sections, - [alias('Supress')][bool] $Suppress = $true - ) - if ($SectionsInput) { - $Output = & $SectionsInput - } else { - $Output = $Sections - } - - if ($Color -or $Color -ne 'None') { - try { - $ThemeColor = ConvertFrom-Color -Color $Color - } catch { - $ErrorMessage = $_.Exception.Message -replace "`n", " " -replace "`r", " " - Write-Warning "Send-TeamsMessage - Color conversion for $Color failed. Error message: $ErrorMessage" - $ThemeColor = $null - } - } - $Body = Add-TeamsBody -MessageTitle $MessageTitle ` - -MessageText $MessageText ` - -ThemeColor $ThemeColor ` - -Sections $Output ` - -MessageSummary $MessageSummary ` - -HideOriginalBody:$HideOriginalBody.IsPresent - Write-Verbose "Send-TeamsMessage - Body $Body" - try { - $Params = @{ - Uri = $Uri - Method = 'Post' - Body = $Body - ContentType = 'application/json; charset=UTF-8' - ErrorAction = 'Stop' - } - if ($Proxy) { - $Params.Proxy = $Proxy - } - $Execute = Invoke-RestMethod @Params - } catch { - $ErrorMessage = $_.Exception.Message -replace "`n", " " -replace "`r", " " - if ($PSBoundParameters.ErrorAction -eq 'Stop') { - Write-Error "Couldn't send message. Error $ErrorMessage" - } else { - Write-Warning "Send-TeamsMessage - Couldn't send message. Error: $ErrorMessage" - } - } - Write-Verbose "Send-TeamsMessage - Execute $Execute" - if ($Execute -like '*failed*' -or $Execute -like '*error*') { - if ($PSBoundParameters.ErrorAction -eq 'Stop') { - Write-Error "Send-TeamsMessage - Couldn't send message. Execute message: $Execute" - } else { - Write-Warning "Send-TeamsMessage - Couldn't send message. Execute message: $Execute" - } - } - if (-not $Suppress) { return $Body } -} - -Register-ArgumentCompleter -CommandName Send-TeamsMessage -ParameterName Color -ScriptBlock { $Script:RGBColors.Keys } \ No newline at end of file diff --git a/Public/Send-TeamsMessageBody.ps1 b/Public/Send-TeamsMessageBody.ps1 deleted file mode 100644 index 386e007..0000000 --- a/Public/Send-TeamsMessageBody.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -function Send-TeamsMessageBody { - [alias('TeamsMessageBody')] - [CmdletBinding()] - param ( - [alias("TeamsID", 'Url')][Parameter(Mandatory = $true)][string]$Uri, - [string] $Body, - [bool] $Supress = $true, - [switch] $Wrap, - [Uri] $Proxy - ) - if ($Wrap) { - $TemporaryBody = ConvertFrom-Json -InputObject $Body - $Wrapper = [ordered]@{ - "type" = "message" - "attachments" = @( - $TemporaryBody - ) - } - $Body = $Wrapper | ConvertTo-Json -Depth 20 - } - Write-Verbose "Send-TeamsMessage - Body $Body" - try { - $Params = @{ - Uri = $Uri - Method = 'Post' - Body = $Body - ContentType = 'application/json; charset=UTF-8' - } - if ($Proxy) { - $Params.Proxy = $Proxy - } - $Execute = Invoke-RestMethod @Params - } catch { - $ErrorMessage = $_.Exception.Message -replace "`n", " " -replace "`r", " " - if ($PSBoundParameters.ErrorAction -eq 'Stop') { - Write-Error "Couldn't send message. Error $ErrorMessage" - } else { - Write-Warning "Send-TeamsMessageBody - Couldn't send message. Error: $ErrorMessage" - } - } - Write-Verbose "Send-TeamsMessageBody - Execute $Execute" - if ($Execute -like '*failed*' -or $Execute -like '*error*') { - if ($PSBoundParameters.ErrorAction -eq 'Stop') { - Write-Error "Send-TeamsMessageBody - Couldn't send message. Execute message: $Execute" - } else { - Write-Warning "Send-TeamsMessageBody - Couldn't send message. Execute message: $Execute" - } - } - if (-not $Supress) { return $Body } -} \ No newline at end of file diff --git a/README.md b/README.md index 5ad2618..4cd2d03 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ ## Main Branch Note -The `main` branch is now the new `TeamsX` direction: reusable C# library plus binary PowerShell cmdlets. +The `main` branch keeps shipping `PSTeams`, but the implementation model has changed. -The old script-function-heavy `PSTeams` surface is preserved on the `legacy` branch, but it is not being continued here. -New work on `main` should target `TeamsX` / `TeamsX.PowerShell` and expose cmdlets, not PowerShell wrapper functions. +`TeamsX` is now the reusable .NET library, `TeamsX.PowerShell` provides thin C# cmdlets over that library, and `Module\PSTeams` is the real shipping module shell. +Migration is 1:1: existing PowerShell functions stay only until equivalent cmdlets are ready, and then those script implementations can be removed. [PSTeams](https://evotec.xyz/hub/scripts/psteams-powershell-module/) is a **PowerShell Module** working on **Windows** / **Linux** and **Mac**. It allows sending notifications to _Microsoft Teams_ via **WebHook Notifications**. It's pretty flexible and provides a bunch of options. @@ -432,7 +432,94 @@ That's it. Whenever there's a new version you simply run the command and you can Remember, that you may need to close, reopen the PowerShell session if you have already used the module before updating it. **The important thing** is if something works for you on production, keep using it till you test the new version on a test computer. I do changes that may not be big, but big enough that auto-update will break your code. For example, small rename to a parameter and your code stops working! Be responsible! -Dependencies: **PSSharedGoods**, **PSWriteColor** and **Connectimo** are only used during development. When published to PSGallery / Releases it's a merged release without any dependencies. +Runtime dependency on `PSSharedGoods` has been removed from the module shell on `main`. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. + +On `main`, the public adaptive surface is now binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. + +`main` also now includes a starter Microsoft Graph delivery path in `TeamsX`, exposed through `New-TeamsGraphTarget`. This lets the typed `Send-TeamsMessage -Message ... -Target ...` path post to Teams chats and channels without introducing large SDK dependencies. + +If you prefer the typed surface directly, the `New-TeamsAdaptive*` cmdlets now expose the richer card and layout options too: + +```powershell +$card = New-TeamsAdaptiveCard ` + -FallbackText 'Build failed' ` + -MinimumHeight 140 ` + -Language 'en' ` + -VerticalContentAlignment center ` + -BackgroundUrl 'https://example.test/background.png' ` + -BackgroundFillMode Cover ` + -AllowImageExpand ` + -FullWidth ` + -Body @( + New-TeamsAdaptiveContainer ` + -Style Emphasis ` + -Bleed ` + -MinimumHeight 120 ` + -Spacing Medium ` + -Items @( + New-TeamsAdaptiveColumnSet ` + -Style Good ` + -Bleed ` + -Columns @( + New-TeamsAdaptiveColumn -WidthInWeight 2 -Items @( + New-TeamsAdaptiveTextBlock -Text 'Pipeline failed' -Weight Bolder -Color Attention + ) + New-TeamsAdaptiveColumn -Width auto -Items @( + New-TeamsAdaptiveImage -Url 'https://example.test/status.png' -AltText 'Status' + ) + ) + ) + ) ` + -Actions @( + New-TeamsAdaptiveOpenUrlAction -Title 'Open build' -Url 'https://example.test/build/42' + New-TeamsAdaptiveSubmitAction -Title 'Acknowledge' + New-TeamsAdaptiveShowCardAction -Title 'Details' -Body @( + New-TeamsAdaptiveTextBlock -Text 'Nested details' + ) -Actions @( + New-TeamsAdaptiveSubmitAction -Title 'Confirm' + ) + ) + +$message = New-TeamsMessage -Summary 'Build notification' -AdaptiveCard $card +$json = $message | ConvertTo-TeamsJson +``` + +Dedicated typed examples live under `Examples\MessageCard\MessageCard-Typed.ps1` and `Examples\Adaptive Card\AdaptiveCard-TypedActions.ps1`. + +Typed wrapper-card models are now available as well: + +```powershell +$target = New-TeamsWebhookTarget -Uri 'https://example.test/webhook' +$heroCard = New-TeamsHeroCard -Title 'Seattle Center Monorail' -Images @( + New-TeamsCardImage -Url 'https://example.test/monorail.jpg' -AlternateText 'Monorail' +) -Buttons @( + New-CardListButton -Type OpenUrl -Title 'Official website' -Value 'https://example.test' +) + +Send-TeamsMessage -HeroCard $heroCard -Target $target + +$json = $heroCard | ConvertTo-TeamsJson +$wrapped = $json | Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Wrap -Supress:$false -WhatIf +``` + +Typed wrapper-card direct sending currently targets incoming and workflow webhooks. Graph delivery remains limited to typed messages and adaptive-card attachments. + +For Graph chat or channel delivery, the starter flow looks like this: + +```powershell +$message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42 stopped in the release stage.' +$target = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessTokenVariableName 'TEAMSX_GRAPH_TOKEN' + +Send-TeamsMessage -Message $message -Target $target +``` + +Current Graph scope on `main`: + +- plain typed messages render to Graph HTML message bodies +- adaptive cards render as Graph attachments +- adaptive-card actions should currently be limited to `Action.OpenUrl` +- Graph targets can use a plain token, a secure string, or an environment-variable-backed token provider +- normal chat/channel posting should use delegated Microsoft Graph tokens; application permissions on these endpoints are documented as migration-only ## Documentation for Message Cards (for development) diff --git a/TeamsX.PowerShell/CmdletConvertToTeamsFact.cs b/TeamsX.PowerShell/CmdletConvertToTeamsFact.cs new file mode 100644 index 0000000..5e77229 --- /dev/null +++ b/TeamsX.PowerShell/CmdletConvertToTeamsFact.cs @@ -0,0 +1,109 @@ +using System.Collections; +using System.IO; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Converts dictionaries and PowerShell objects into Teams facts. +/// +[Cmdlet(VerbsData.ConvertTo, "TeamsFact")] +[OutputType(typeof(TeamsMessageFact))] +public sealed class CmdletConvertToTeamsFact : PSCmdlet { + [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)] + public object? InputObject { get; set; } + + protected override void ProcessRecord() { + foreach (var value in ExpandInput(InputObject)) { + ConvertInput(value); + } + } + + private void ConvertInput(object? input) { + if (input is null) { + return; + } + + if (TryGetDictionary(input, out var dictionary)) { + foreach (DictionaryEntry entry in dictionary) { + WriteFact(entry.Key?.ToString(), entry.Value?.ToString()); + } + return; + } + + var type = GetInputType(input); + if (type is not null && IsPrimitiveInput(type)) { + ThrowTerminatingError(new ErrorRecord( + new InvalidDataException("The input is neither a PSObject nor a Hashtable. Operation aborted."), + "InvalidTeamsFactInput", + ErrorCategory.InvalidData, + input)); + } + + var properties = PSObject.AsPSObject(input).Properties; + foreach (var property in properties) { + WriteFact(property.Name, property.Value?.ToString()); + } + } + + private void WriteFact(string? name, string? value) { + WriteObject(new TeamsMessageFact { + Name = name, + Value = value + }); + } + + private static IEnumerable ExpandInput(object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + yield break; + } + + if (value is IEnumerable enumerable && value is not string && value is not IDictionary) { + foreach (var entry in enumerable) { + yield return entry; + } + yield break; + } + + yield return input; + } + + private static bool TryGetDictionary(object input, out IDictionary dictionary) { + if (input is IDictionary directDictionary) { + dictionary = directDictionary; + return true; + } + + if (input is PSObject psObject && psObject.BaseObject is IDictionary baseDictionary) { + dictionary = baseDictionary; + return true; + } + + dictionary = null!; + return false; + } + + private static Type? GetInputType(object input) { + if (input is PSObject psObject) { + return psObject.BaseObject?.GetType() ?? input.GetType(); + } + + return input.GetType(); + } + + internal static bool IsPrimitiveInput(Type type) { + if (type.IsEnum || type.IsPrimitive) { + return true; + } + + return type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(Uri) + || type == typeof(byte[]); + } +} diff --git a/TeamsX.PowerShell/CmdletConvertToTeamsJson.cs b/TeamsX.PowerShell/CmdletConvertToTeamsJson.cs index f30e3b8..e9bf4c5 100644 --- a/TeamsX.PowerShell/CmdletConvertToTeamsJson.cs +++ b/TeamsX.PowerShell/CmdletConvertToTeamsJson.cs @@ -7,9 +7,41 @@ namespace TeamsX.PowerShell; [OutputType(typeof(string))] public sealed class CmdletConvertToTeamsJson : PSCmdlet { [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] - public TeamsMessageRequest Message { get; set; } = null!; + public object InputObject { get; set; } = null!; protected override void ProcessRecord() { - WriteObject(WebhookMessageRenderer.Render(Message)); + switch (InputObject) { + case TeamsMessageRequest message: + WriteObject(WebhookMessageRenderer.Render(message)); + return; + case TeamsHeroCard heroCard: + WriteObject(TeamsWrapperCardRenderer.Render(heroCard)); + return; + case TeamsThumbnailCard thumbnailCard: + WriteObject(TeamsWrapperCardRenderer.Render(thumbnailCard)); + return; + case TeamsListCard listCard: + WriteObject(TeamsWrapperCardRenderer.Render(listCard)); + return; + case PSObject { BaseObject: TeamsMessageRequest message }: + WriteObject(WebhookMessageRenderer.Render(message)); + return; + case PSObject { BaseObject: TeamsHeroCard heroCard }: + WriteObject(TeamsWrapperCardRenderer.Render(heroCard)); + return; + case PSObject { BaseObject: TeamsThumbnailCard thumbnailCard }: + WriteObject(TeamsWrapperCardRenderer.Render(thumbnailCard)); + return; + case PSObject { BaseObject: TeamsListCard listCard }: + WriteObject(TeamsWrapperCardRenderer.Render(listCard)); + return; + default: + ThrowTerminatingError(new ErrorRecord( + new PSArgumentException("ConvertTo-TeamsJson supports TeamsMessageRequest, TeamsHeroCard, TeamsThumbnailCard, and TeamsListCard inputs."), + "UnsupportedTeamsJsonInput", + ErrorCategory.InvalidArgument, + InputObject)); + return; + } } } diff --git a/TeamsX.PowerShell/CmdletConvertToTeamsSection.cs b/TeamsX.PowerShell/CmdletConvertToTeamsSection.cs new file mode 100644 index 0000000..e92c9bb --- /dev/null +++ b/TeamsX.PowerShell/CmdletConvertToTeamsSection.cs @@ -0,0 +1,138 @@ +using System.Collections; +using System.IO; +using System.Management.Automation; +using System.Text.RegularExpressions; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Converts dictionaries and PowerShell objects into Teams sections. +/// +[Cmdlet(VerbsData.ConvertTo, "TeamsSection")] +[OutputType(typeof(TeamsMessageSection))] +public sealed class CmdletConvertToTeamsSection : PSCmdlet { + private static readonly Regex SectionTitleRegexValue = new("([A-Z])", RegexOptions.Compiled); + + [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)] + public object? InputObject { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public string? SectionTitleProperty { get; set; } + + protected override void ProcessRecord() { + foreach (var value in ExpandInput(InputObject)) { + if (value is null) { + continue; + } + + TeamsMessageSection section; + try { + section = BuildSection(value); + } catch (InvalidDataException exception) { + ThrowTerminatingError(new ErrorRecord( + exception, + "InvalidTeamsSectionInput", + ErrorCategory.InvalidData, + value)); + return; + } + + WriteObject(section); + } + } + + private TeamsMessageSection BuildSection(object value) { + var section = new TeamsMessageSection(); + foreach (var fact in ConvertFacts(value)) { + section.Facts.Add(fact); + } + + var titleProperty = SectionTitleProperty; + if (!string.IsNullOrWhiteSpace(titleProperty)) { + var propertyName = titleProperty!; + var propertyValue = GetPropertyValue(value, propertyName); + section.ActivityTitle = string.IsNullOrWhiteSpace(propertyValue) + ? FormatSectionTitle(propertyName) + : $"{FormatSectionTitle(propertyName)} {propertyValue}"; + } + + return section; + } + + private static IEnumerable ConvertFacts(object value) { + if (TryGetDictionary(value, out var dictionary)) { + foreach (DictionaryEntry entry in dictionary) { + yield return new TeamsMessageFact { + Name = entry.Key?.ToString(), + Value = entry.Value?.ToString() + }; + } + yield break; + } + + var type = GetInputType(value); + if (type is not null && CmdletConvertToTeamsFact.IsPrimitiveInput(type)) { + throw new InvalidDataException("The input is neither a PSObject nor a Hashtable. Operation aborted."); + } + + foreach (var property in PSObject.AsPSObject(value).Properties) { + yield return new TeamsMessageFact { + Name = property.Name, + Value = property.Value?.ToString() + }; + } + } + + private static string? GetPropertyValue(object value, string propertyName) { + if (TryGetDictionary(value, out var dictionary) && dictionary.Contains(propertyName)) { + return dictionary[propertyName]?.ToString(); + } + + var property = PSObject.AsPSObject(value).Properties[propertyName]; + return property?.Value?.ToString(); + } + + private static IEnumerable ExpandInput(object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + yield break; + } + + if (value is IEnumerable enumerable && value is not string && value is not IDictionary) { + foreach (var entry in enumerable) { + yield return entry; + } + yield break; + } + + yield return input; + } + + private static bool TryGetDictionary(object input, out IDictionary dictionary) { + if (input is IDictionary directDictionary) { + dictionary = directDictionary; + return true; + } + + if (input is PSObject psObject && psObject.BaseObject is IDictionary baseDictionary) { + dictionary = baseDictionary; + return true; + } + + dictionary = null!; + return false; + } + + private static Type? GetInputType(object input) { + if (input is PSObject psObject) { + return psObject.BaseObject?.GetType() ?? input.GetType(); + } + + return input.GetType(); + } + + private static string FormatSectionTitle(string propertyName) { + return SectionTitleRegexValue.Replace(propertyName, " $1").Trim(); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs b/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs new file mode 100644 index 0000000..c2f0583 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs @@ -0,0 +1,87 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive action backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveAction")] +[OutputType(typeof(TeamsAdaptiveAction))] +public sealed class CmdletNewAdaptiveAction : PSCmdlet { + [Parameter(Mandatory = false)] + public ScriptBlock? Body { get; set; } + + [Parameter(Mandatory = false)] + public ScriptBlock? Actions { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.ShowCard", "Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string Type { get; set; } = "Action.ShowCard"; + + [Parameter(Mandatory = false)] + public string? ActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + protected override void ProcessRecord() { + if (!string.IsNullOrWhiteSpace(ActionUrl)) { + WriteObject(new TeamsAdaptiveOpenUrlAction { + Title = Title ?? string.Empty, + Url = ActionUrl ?? string.Empty + }); + return; + } + + if (string.Equals(Type, "Action.Submit", StringComparison.OrdinalIgnoreCase)) { + WriteObject(new TeamsAdaptiveSubmitAction { + Title = Title ?? string.Empty + }); + return; + } + + if (string.Equals(Type, "Action.ToggleVisibility", StringComparison.OrdinalIgnoreCase)) { + WriteObject(new TeamsAdaptiveToggleVisibilityAction { + Title = Title ?? string.Empty + }); + return; + } + + var card = BuildNestedCard(); + WriteObject(new TeamsAdaptiveShowCardAction { + Title = Title ?? string.Empty, + Card = card + }); + } + + private Dictionary? BuildNestedCard() { + if (Body is null && Actions is null) { + return null; + } + + var card = new Dictionary { + ["type"] = "AdaptiveCard" + }; + + if (Body is not null) { + card["body"] = Body.Invoke() + .Select(Unwrap) + .Where(static value => value is not null) + .ToArray(); + } + + if (Actions is not null) { + card["actions"] = Actions.Invoke() + .Select(Unwrap) + .Where(static value => value is not null) + .ToArray(); + } + + return card; + } + + private static object? Unwrap(object? value) { + return value is PSObject psObject ? psObject.BaseObject : value; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveActionSet.cs b/TeamsX.PowerShell/CmdletNewAdaptiveActionSet.cs new file mode 100644 index 0000000..9f93097 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveActionSet.cs @@ -0,0 +1,106 @@ +using System.Collections; +using System.Linq; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive action set backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveActionSet")] +[OutputType(typeof(TeamsAdaptiveActionSet))] +public sealed class CmdletNewAdaptiveActionSet : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Action { get; set; } + + protected override void ProcessRecord() { + if (Action is null) { + return; + } + + var actionSet = new TeamsAdaptiveActionSet(); + foreach (var item in Action.Invoke()) { + ApplyAction(actionSet, item); + } + + if (actionSet.Actions.Count > 0) { + WriteObject(actionSet); + } + } + + private static void ApplyAction(TeamsAdaptiveActionSet actionSet, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsAdaptiveAction adaptiveAction) { + actionSet.Actions.Add(adaptiveAction); + return; + } + + if (value is IDictionary dictionary && TryCreateAction(dictionary, out var converted)) { + actionSet.Actions.Add(converted); + } + } + + private static bool TryCreateAction(IDictionary dictionary, out TeamsAdaptiveAction action) { + action = null!; + var type = GetDictionaryString(dictionary, "type") ?? GetDictionaryString(dictionary, "Type"); + var title = GetDictionaryString(dictionary, "title") ?? string.Empty; + + switch (type) { + case "Action.OpenUrl": + action = new TeamsAdaptiveOpenUrlAction { + Title = title, + Url = GetDictionaryString(dictionary, "url") ?? string.Empty + }; + return true; + case "Action.Submit": + action = new TeamsAdaptiveSubmitAction { + Title = title + }; + return true; + case "Action.ToggleVisibility": + var toggle = new TeamsAdaptiveToggleVisibilityAction { + Title = title + }; + if (dictionary.Contains("targetElements") && dictionary["targetElements"] is IEnumerable enumerable && dictionary["targetElements"] is not string) { + foreach (var item in enumerable) { + var text = item?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) { + toggle.TargetElements.Add(text!); + } + } + } + action = toggle; + return true; + case "Action.ShowCard": + Dictionary? card = null; + if (dictionary.Contains("card") && dictionary["card"] is IDictionary cardDictionary) { + card = cardDictionary + .Cast() + .ToDictionary( + entry => entry.Key?.ToString() ?? string.Empty, + entry => (object?)entry.Value); + } + + action = new TeamsAdaptiveShowCardAction { + Title = title, + Card = card + }; + return true; + default: + return false; + } + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs b/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs new file mode 100644 index 0000000..c3ce5d3 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs @@ -0,0 +1,175 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive card message backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveCard", SupportsShouldProcess = true)] +[OutputType(typeof(string))] +public sealed class CmdletNewAdaptiveCard : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Body { get; set; } + + [Parameter(Mandatory = false)] + public ScriptBlock? Action { get; set; } + + [Alias("TeamsID", "Url")] + [Parameter(Mandatory = false)] + public Uri? Uri { get; set; } + + [Parameter(Mandatory = false)] + public string? FallBackText { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public string? Speak { get; set; } + + [Parameter(Mandatory = false)] + public string? Language { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundUrl { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Cover", "RepeatHorizontally", "RepeatVertically", "Repeat")] + public string? BackgroundFillMode { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("left", "center", "right")] + public string? BackgroundHorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? BackgroundVerticalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter FullWidth { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowImageExpand { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter ReturnJson { get; set; } + + protected override void ProcessRecord() { + var card = new TeamsAdaptiveCard { + FallbackText = FallBackText, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Speak = Speak, + Language = Language, + VerticalContentAlignment = VerticalContentAlignment, + BackgroundImage = BuildBackgroundImage(), + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement), + AllowImageExpand = AllowImageExpand.IsPresent ? true : null, + FullWidth = FullWidth.IsPresent + }; + + if (Body is not null) { + foreach (var item in Body.Invoke()) { + var value = item is PSObject psObject ? psObject.BaseObject : item; + if (value is TeamsAdaptiveMention mention) { + card.Mentions.Add(mention); + continue; + } + + if (value is TeamsAdaptiveCardElement element) { + card.Body.Add(element); + } + } + } + + if (Action is not null) { + foreach (var item in Action.Invoke()) { + var value = item is PSObject psObject ? psObject.BaseObject : item; + if (value is TeamsAdaptiveAction adaptiveAction) { + card.Actions.Add(adaptiveAction); + } + } + } + + var request = new TeamsMessageRequest { + AdaptiveCard = card + }; + var jsonBody = WebhookMessageRenderer.Render(request); + + if (Uri is null) { + WriteObject(jsonBody); + return; + } + + if (!ShouldProcess(Uri.Host, "Send Teams adaptive card using IncomingWebhook")) { + if (ReturnJson.IsPresent) { + WriteObject(jsonBody); + } + + return; + } + + var client = TeamsPowerShellDeliverySupport.CreateClient(null); + var target = TeamsMessageTarget.ForIncomingWebhook(Uri); + var result = client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); + + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-AdaptiveCard"); + + if (ReturnJson.IsPresent) { + WriteObject(jsonBody); + } + } + + private Dictionary? BuildBackgroundImage() { + if (string.IsNullOrWhiteSpace(BackgroundUrl) && + string.IsNullOrWhiteSpace(BackgroundFillMode) && + string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment) && + string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + return null; + } + + var backgroundImage = new Dictionary(); + if (!string.IsNullOrWhiteSpace(BackgroundFillMode)) { + backgroundImage["fillMode"] = BackgroundFillMode; + } + + if (!string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment)) { + backgroundImage["horizontalAlignment"] = BackgroundHorizontalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + backgroundImage["verticalAlignment"] = BackgroundVerticalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundUrl)) { + backgroundImage["url"] = BackgroundUrl; + } + + return backgroundImage; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveColumn.cs b/TeamsX.PowerShell/CmdletNewAdaptiveColumn.cs new file mode 100644 index 0000000..df452a7 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveColumn.cs @@ -0,0 +1,121 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive column backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveColumn")] +[OutputType(typeof(TeamsAdaptiveColumn))] +public sealed class CmdletNewAdaptiveColumn : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Items { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Auto", "Weighted")] + public string? Width { get; set; } + + [Parameter(Mandatory = false)] + public int WidthInWeight { get; set; } + + [Parameter(Mandatory = false)] + public int WidthInPixels { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Top", "Center", "Bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + + protected override void ProcessRecord() { + if (Items is null) { + return; + } + + var column = new TeamsAdaptiveColumn { + Width = GetWidthValue(), + Height = Height, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + HorizontalAlignment = HorizontalAlignment, + VerticalContentAlignment = VerticalContentAlignment, + Spacing = Spacing, + Style = Style, + IsVisible = Hidden.IsPresent ? false : null, + Separator = Separator.IsPresent ? true : null, + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement) + }; + + foreach (var item in Items.Invoke()) { + var value = item is PSObject psObject ? psObject.BaseObject : item; + if (value is TeamsAdaptiveCardElement element) { + column.Items.Add(element); + } + } + + if (column.Items.Count > 0) { + WriteObject(column); + } + } + + private string? GetWidthValue() { + if (WidthInWeight > 0) { + return WidthInWeight.ToString(); + } + + if (WidthInPixels > 0) { + return $"{WidthInPixels}px"; + } + + var width = Width; + if (string.IsNullOrWhiteSpace(width)) { + return null; + } + + return width!.ToLowerInvariant(); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveColumnSet.cs b/TeamsX.PowerShell/CmdletNewAdaptiveColumnSet.cs new file mode 100644 index 0000000..9fe55e0 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveColumnSet.cs @@ -0,0 +1,66 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive column set backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveColumnSet")] +[OutputType(typeof(TeamsAdaptiveColumnSet))] +public sealed class CmdletNewAdaptiveColumnSet : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Columns { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Bleed { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + protected override void ProcessRecord() { + if (Columns is null) { + return; + } + + var columnSet = new TeamsAdaptiveColumnSet { + Style = Style, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Bleed = Bleed.IsPresent ? true : null, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + HorizontalAlignment = HorizontalAlignment, + Height = Height + }; + + foreach (var item in Columns.Invoke()) { + var value = item is PSObject psObject ? psObject.BaseObject : item; + if (value is TeamsAdaptiveColumn column) { + columnSet.Columns.Add(column); + } + } + + if (columnSet.Columns.Count > 0) { + WriteObject(columnSet); + } + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveContainer.cs b/TeamsX.PowerShell/CmdletNewAdaptiveContainer.cs new file mode 100644 index 0000000..6156d6e --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveContainer.cs @@ -0,0 +1,145 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive container backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveContainer")] +[OutputType(typeof(TeamsAdaptiveContainer))] +public sealed class CmdletNewAdaptiveContainer : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Items { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Bleed { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundUrl { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Cover", "RepeatHorizontally", "RepeatVertically", "Repeat")] + public string? BackgroundFillMode { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("left", "center", "right")] + public string? BackgroundHorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? BackgroundVerticalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + + protected override void ProcessRecord() { + if (Items is null) { + return; + } + + var container = new TeamsAdaptiveContainer { + Id = Id, + Spacing = Spacing, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + Style = Style, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Bleed = Bleed.IsPresent ? true : null, + VerticalContentAlignment = VerticalContentAlignment, + Separator = Separator.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null, + BackgroundImage = BuildBackgroundImage(), + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement) + }; + + foreach (var item in Items.Invoke()) { + var value = item is PSObject psObject ? psObject.BaseObject : item; + if (value is TeamsAdaptiveCardElement element) { + container.Items.Add(element); + } + } + + if (container.Items.Count > 0) { + WriteObject(container); + } + } + + private Dictionary? BuildBackgroundImage() { + if (string.IsNullOrWhiteSpace(BackgroundUrl) && + string.IsNullOrWhiteSpace(BackgroundFillMode) && + string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment) && + string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + return null; + } + + var backgroundImage = new Dictionary(); + if (!string.IsNullOrWhiteSpace(BackgroundFillMode)) { + backgroundImage["fillMode"] = BackgroundFillMode; + } + + if (!string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment)) { + backgroundImage["horizontalAlignment"] = BackgroundHorizontalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + backgroundImage["verticalAlignment"] = BackgroundVerticalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundUrl)) { + backgroundImage["url"] = BackgroundUrl; + } + + return backgroundImage; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveFact.cs b/TeamsX.PowerShell/CmdletNewAdaptiveFact.cs new file mode 100644 index 0000000..7a0d44c --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveFact.cs @@ -0,0 +1,24 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive fact backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveFact")] +[OutputType(typeof(TeamsAdaptiveFact))] +public sealed class CmdletNewAdaptiveFact : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Title { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public string? Value { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsAdaptiveFact { + Title = Title ?? string.Empty, + Value = Value ?? string.Empty + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveFactSet.cs b/TeamsX.PowerShell/CmdletNewAdaptiveFactSet.cs new file mode 100644 index 0000000..c7ecd35 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveFactSet.cs @@ -0,0 +1,85 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive fact set backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveFactSet")] +[OutputType(typeof(TeamsAdaptiveFactSet))] +public sealed class CmdletNewAdaptiveFactSet : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Facts { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + protected override void ProcessRecord() { + if (Facts is null) { + return; + } + + var factSet = new TeamsAdaptiveFactSet { + Height = Height, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null + }; + + foreach (var item in Facts.Invoke()) { + ApplyFact(factSet, item); + } + + if (factSet.Facts.Count > 0) { + WriteObject(factSet); + } + } + + private static void ApplyFact(TeamsAdaptiveFactSet factSet, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsAdaptiveFact fact) { + factSet.Facts.Add(fact); + return; + } + + if (value is IDictionary dictionary && TryCreateFact(dictionary, out var converted)) { + factSet.Facts.Add(converted); + } + } + + private static bool TryCreateFact(IDictionary dictionary, out TeamsAdaptiveFact fact) { + fact = null!; + var title = GetDictionaryString(dictionary, "title") ?? GetDictionaryString(dictionary, "Title"); + var value = GetDictionaryString(dictionary, "value") ?? GetDictionaryString(dictionary, "Value"); + if (title is null && value is null) { + return false; + } + + fact = new TeamsAdaptiveFact { + Title = title ?? string.Empty, + Value = value ?? string.Empty + }; + return true; + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveImage.cs b/TeamsX.PowerShell/CmdletNewAdaptiveImage.cs new file mode 100644 index 0000000..dddacde --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveImage.cs @@ -0,0 +1,98 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive image backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveImage")] +[OutputType(typeof(TeamsAdaptiveImage))] +public sealed class CmdletNewAdaptiveImage : PSCmdlet { + [Alias("Link")] + [Parameter(Mandatory = false, Position = 0)] + public string? Url { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("person", "default")] + public string? Style { get; set; } + + [Alias("Alt", "AltText")] + [Parameter(Mandatory = false)] + public string? AlternateText { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Auto", "Stretch", "Small", "Medium", "Large")] + public string? Size { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public int HeightInPixels { get; set; } + + [Parameter(Mandatory = false)] + public int WidthInPixels { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundColor { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + + protected override void ProcessRecord() { + var image = new TeamsAdaptiveImage { + Id = Id, + Url = Url ?? string.Empty, + Size = Size, + AltText = AlternateText, + Style = Style, + HorizontalAlignment = HorizontalAlignment, + Height = HeightInPixels > 0 ? $"{HeightInPixels}px" : Height, + Width = WidthInPixels > 0 ? $"{WidthInPixels}px" : null, + Spacing = Spacing, + BackgroundColor = TeamsColorUtility.NormalizeToHex(BackgroundColor), + Separator = Separator.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null, + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement) + }; + + WriteObject(image); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveImageSet.cs b/TeamsX.PowerShell/CmdletNewAdaptiveImageSet.cs new file mode 100644 index 0000000..ba09401 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveImageSet.cs @@ -0,0 +1,117 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive image set backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveImageSet")] +[OutputType(typeof(TeamsAdaptiveImageSet))] +public sealed class CmdletNewAdaptiveImageSet : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? Images { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Small", "Medium", "Large")] + public string? Size { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + protected override void ProcessRecord() { + if (Images is null) { + return; + } + + var imageSet = new TeamsAdaptiveImageSet { + Id = Id, + ImageSize = Size, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null + }; + + foreach (var item in Images.Invoke()) { + ApplyImage(imageSet, item); + } + + if (imageSet.Images.Count > 0) { + WriteObject(imageSet); + } + } + + private static void ApplyImage(TeamsAdaptiveImageSet imageSet, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsAdaptiveImage image) { + imageSet.Images.Add(image); + return; + } + + if (value is IDictionary dictionary && TryCreateImage(dictionary, out var converted)) { + imageSet.Images.Add(converted); + } + } + + private static bool TryCreateImage(IDictionary dictionary, out TeamsAdaptiveImage image) { + image = null!; + var resolvedUrl = GetDictionaryString(dictionary, "url") ?? GetDictionaryString(dictionary, "Url"); + if (string.IsNullOrWhiteSpace(resolvedUrl)) { + return false; + } + + image = new TeamsAdaptiveImage { + Id = GetDictionaryString(dictionary, "id") ?? GetDictionaryString(dictionary, "Id"), + Url = resolvedUrl!, + AltText = GetDictionaryString(dictionary, "alt") ?? GetDictionaryString(dictionary, "altText") ?? GetDictionaryString(dictionary, "AltText"), + Size = GetDictionaryString(dictionary, "size") ?? GetDictionaryString(dictionary, "Size"), + Style = GetDictionaryString(dictionary, "style") ?? GetDictionaryString(dictionary, "Style"), + HorizontalAlignment = GetDictionaryString(dictionary, "horizontalAlignment") ?? GetDictionaryString(dictionary, "HorizontalAlignment"), + Height = GetDictionaryString(dictionary, "height") ?? GetDictionaryString(dictionary, "Height"), + Width = GetDictionaryString(dictionary, "width") ?? GetDictionaryString(dictionary, "Width"), + Spacing = GetDictionaryString(dictionary, "spacing") ?? GetDictionaryString(dictionary, "Spacing"), + BackgroundColor = GetDictionaryString(dictionary, "backgroundColor") ?? GetDictionaryString(dictionary, "BackgroundColor"), + Separator = GetDictionaryBoolean(dictionary, "separator") ?? GetDictionaryBoolean(dictionary, "Separator"), + IsVisible = GetDictionaryBoolean(dictionary, "isVisible") ?? GetDictionaryBoolean(dictionary, "IsVisible") + }; + return true; + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } + + private static bool? GetDictionaryBoolean(IDictionary dictionary, string key) { + var value = GetDictionaryString(dictionary, key); + return bool.TryParse(value, out var parsed) ? parsed : null; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveLineBreak.cs b/TeamsX.PowerShell/CmdletNewAdaptiveLineBreak.cs new file mode 100644 index 0000000..b916252 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveLineBreak.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive line break backed by a newline text block. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveLineBreak")] +[OutputType(typeof(TeamsAdaptiveTextBlock))] +public sealed class CmdletNewAdaptiveLineBreak : PSCmdlet { + protected override void ProcessRecord() { + WriteObject(new TeamsAdaptiveTextBlock { + Text = "\n" + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveMedia.cs b/TeamsX.PowerShell/CmdletNewAdaptiveMedia.cs new file mode 100644 index 0000000..4181516 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveMedia.cs @@ -0,0 +1,101 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive media element backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveMedia")] +[OutputType(typeof(TeamsAdaptiveMedia))] +public sealed class CmdletNewAdaptiveMedia : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public ScriptBlock Sources { get; set; } = null!; + + [Parameter(Mandatory = false)] + public string? PosterUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? AlternateText { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + protected override void ProcessRecord() { + var media = new TeamsAdaptiveMedia { + Poster = PosterUrl, + AltText = AlternateText, + Spacing = Spacing, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + Id = Id, + Separator = Separator.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null + }; + + foreach (var item in Sources.Invoke()) { + ApplySource(media, item); + } + + WriteObject(media); + } + + private static void ApplySource(TeamsAdaptiveMedia media, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsAdaptiveMediaSource mediaSource) { + media.Sources.Add(mediaSource); + return; + } + + if (value is IDictionary dictionary && TryCreateMediaSource(dictionary, out var converted)) { + media.Sources.Add(converted); + } + } + + private static bool TryCreateMediaSource(IDictionary dictionary, out TeamsAdaptiveMediaSource mediaSource) { + mediaSource = null!; + + var mimeType = GetDictionaryString(dictionary, "mimeType") ?? GetDictionaryString(dictionary, "MimeType"); + var url = GetDictionaryString(dictionary, "url") ?? GetDictionaryString(dictionary, "Url"); + if (string.IsNullOrWhiteSpace(mimeType) && string.IsNullOrWhiteSpace(url)) { + return false; + } + + mediaSource = new TeamsAdaptiveMediaSource { + MimeType = mimeType ?? string.Empty, + Url = url ?? string.Empty + }; + return true; + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveMediaSource.cs b/TeamsX.PowerShell/CmdletNewAdaptiveMediaSource.cs new file mode 100644 index 0000000..dea3b47 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveMediaSource.cs @@ -0,0 +1,24 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive media source backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveMediaSource")] +[OutputType(typeof(TeamsAdaptiveMediaSource))] +public sealed class CmdletNewAdaptiveMediaSource : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Type { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public string? Url { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsAdaptiveMediaSource { + MimeType = Type ?? string.Empty, + Url = Url ?? string.Empty + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveMention.cs b/TeamsX.PowerShell/CmdletNewAdaptiveMention.cs new file mode 100644 index 0000000..99f85da --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveMention.cs @@ -0,0 +1,34 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive mention backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveMention")] +[OutputType(typeof(TeamsAdaptiveMention))] +public sealed class CmdletNewAdaptiveMention : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public string Text { get; set; } = string.Empty; + + [Parameter(Mandatory = true, Position = 1)] + public string UserPrincipalName { get; set; } = string.Empty; + + [Parameter(Mandatory = false, Position = 2)] + public string? Name { get; set; } + + protected override void ProcessRecord() { + var mentionText = Text.IndexOf("", StringComparison.OrdinalIgnoreCase) >= 0 + ? Text + : $"{Text}"; + + WriteObject(new TeamsAdaptiveMention { + Text = mentionText, + Mentioned = new TeamsMentionedIdentity { + Id = UserPrincipalName, + Name = Name + } + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveRichTextBlock.cs b/TeamsX.PowerShell/CmdletNewAdaptiveRichTextBlock.cs new file mode 100644 index 0000000..4bda221 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveRichTextBlock.cs @@ -0,0 +1,119 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive rich text block backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveRichTextBlock", DefaultParameterSetName = "Text")] +[OutputType(typeof(TeamsAdaptiveRichTextBlock))] +public sealed class CmdletNewAdaptiveRichTextBlock : PSCmdlet { + [Parameter(Mandatory = true, ParameterSetName = "Text")] + public string[] Text { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + [ValidateSet("Accent", "Default", "Dark", "Light", "Good", "Warning", "Attention")] + public string[] Color { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + public bool[] Subtle { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + [Alias("FontSize")] + [ValidateSet("Small", "Default", "Medium", "Large", "ExtraLarge")] + public string[] Size { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + [Alias("FontWeight")] + [ValidateSet("Lighter", "Default", "Bolder")] + public string[] Weight { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + public bool[] Highlight { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + public bool[] Italic { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + public bool[] StrikeThrough { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false, ParameterSetName = "Text")] + [ValidateSet("Default", "Monospace")] + public string[] FontType { get; set; } = Array.Empty(); + + [Parameter(Mandatory = true, ParameterSetName = "Inline")] + public TeamsAdaptiveTextRun[] Inlines { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + protected override void ProcessRecord() { + var block = new TeamsAdaptiveRichTextBlock { + Id = Id, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null + }; + + if (ParameterSetName == "Inline") { + foreach (var inline in Inlines ?? Array.Empty()) { + if (inline is not null) { + block.Inlines.Add(inline); + } + } + + if (block.Inlines.Count > 0) { + WriteObject(block); + } + + return; + } + + for (var i = 0; i < Text.Length; i++) { + block.Inlines.Add(new TeamsAdaptiveTextRun { + Text = Text[i], + Color = GetValue(Color, i), + Subtle = GetBoolean(Subtle, i), + Size = GetValue(Size, i), + Weight = GetValue(Weight, i), + Highlight = GetBoolean(Highlight, i), + Italic = GetBoolean(Italic, i), + StrikeThrough = GetBoolean(StrikeThrough, i), + FontType = GetValue(FontType, i) + }); + } + + if (block.Inlines.Count > 0) { + WriteObject(block); + } + } + + private static string? GetValue(string[] values, int index) { + return values.Length > index ? values[index] : null; + } + + private static bool? GetBoolean(bool[] values, int index) { + return values.Length > index ? values[index] : null; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveTable.cs b/TeamsX.PowerShell/CmdletNewAdaptiveTable.cs new file mode 100644 index 0000000..7053eee --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveTable.cs @@ -0,0 +1,264 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive table by projecting objects into column sets. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveTable")] +[OutputType(typeof(TeamsAdaptiveColumnSet))] +public sealed class CmdletNewAdaptiveTable : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public object[]? DataTable { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Auto", "Stretch")] + public string Width { get; set; } = "Stretch"; + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Dark", "Light", "Good", "Warning", "Attention")] + public string HeaderColor { get; set; } = "Accent"; + + [Alias("HeaderFontWeight")] + [Parameter(Mandatory = false)] + [ValidateSet("Lighter", "Default", "Bolder")] + public string HeaderWeight { get; set; } = "Bolder"; + + [Alias("HeaderFontSize")] + [Parameter(Mandatory = false)] + [ValidateSet("Small", "Default", "Medium", "Large", "ExtraLarge")] + public string? HeaderSize { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter HeaderHighlight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter HeaderItalic { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter HeaderStrikeThrough { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Default", "Monospace")] + public string? HeaderFontType { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? HeaderSpacing { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HeaderHorizontalAlignment { get; set; } + + [Alias("HeaderBlockElementHeight")] + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? HeaderHeight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter HeaderSubtle { get; set; } + + [Parameter(Mandatory = false)] + public int HeaderMaximumLines { get; set; } + + [Alias("FontWeight")] + [Parameter(Mandatory = false)] + [ValidateSet("Lighter", "Default", "Bolder")] + public string? Weight { get; set; } + + [Alias("FontSize")] + [Parameter(Mandatory = false)] + [ValidateSet("Small", "Default", "Medium", "Large", "ExtraLarge")] + public string? Size { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Dark", "Light", "Good", "Warning", "Attention")] + public string? Color { get; set; } + + [Parameter(Mandatory = false)] + public bool Highlight { get; set; } + + [Parameter(Mandatory = false)] + public bool Italic { get; set; } + + [Parameter(Mandatory = false)] + public bool StrikeThrough { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Default", "Monospace")] + public string[] FontType { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Wrap { get; set; } + + [Alias("BlockElementHeight")] + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Subtle { get; set; } + + [Parameter(Mandatory = false)] + public int MaximumLines { get; set; } + + [Alias("HashTableAsCustomObject")] + [Parameter(Mandatory = false)] + public SwitchParameter DictionaryAsCustomObject { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter DisableHeaderColumnSeparators { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter DisableRowSeparators { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter DisableColumnSeparators { get; set; } + + protected override void ProcessRecord() { + if (DataTable is not { Length: > 0 }) { + return; + } + + var rows = DataTable + .Where(static row => row is not null) + .ToArray(); + if (rows.Length == 0) { + return; + } + + var firstRow = rows[0]; + if (firstRow is IDictionary dictionary) { + if (DictionaryAsCustomObject.IsPresent) { + WriteDictionaryAsCustomObjectTable(rows.Cast().ToArray(), dictionary); + return; + } + + WriteDictionaryAsNameValueTable(rows.Cast().ToArray()); + return; + } + + WriteObjectTable(rows); + } + + private void WriteDictionaryAsCustomObjectTable(IDictionary[] rows, IDictionary firstRow) { + var keys = firstRow.Keys.Cast() + .Select(static key => key?.ToString() ?? string.Empty) + .ToArray(); + + WriteObject(CreateColumnSet( + keys.Select(key => CreateColumn( + key, + separator: !DisableHeaderColumnSeparators.IsPresent, + header: true)))); + + foreach (var row in rows) { + WriteObject(CreateColumnSet( + keys.Select(key => CreateColumn( + row.Contains(key) ? row[key] : null, + separator: !DisableColumnSeparators.IsPresent)), + separator: !DisableRowSeparators.IsPresent)); + } + } + + private void WriteDictionaryAsNameValueTable(IDictionary[] rows) { + WriteObject(CreateColumnSet(new[] { + CreateColumn("Name", separator: !DisableHeaderColumnSeparators.IsPresent, header: true), + CreateColumn("Value", separator: !DisableHeaderColumnSeparators.IsPresent, header: true) + })); + + foreach (var row in rows) { + foreach (var key in row.Keys.Cast()) { + var keyText = key?.ToString(); + var value = key is null ? null : row[key]; + + WriteObject(CreateColumnSet(new[] { + CreateColumn(keyText, separator: !DisableColumnSeparators.IsPresent), + CreateColumn(value, separator: !DisableColumnSeparators.IsPresent) + }, separator: !DisableRowSeparators.IsPresent)); + } + } + } + + private void WriteObjectTable(object[] rows) { + var propertyNames = PSObject.AsPSObject(rows[0]).Properties + .Select(static property => property.Name) + .ToArray(); + + WriteObject(CreateColumnSet( + propertyNames.Select(name => CreateColumn( + name, + separator: !DisableHeaderColumnSeparators.IsPresent, + header: true)))); + + foreach (var row in rows) { + var rowObject = PSObject.AsPSObject(row); + WriteObject(CreateColumnSet( + propertyNames.Select(name => CreateColumn( + rowObject.Properties[name]?.Value, + separator: !DisableColumnSeparators.IsPresent)), + separator: !DisableRowSeparators.IsPresent)); + } + } + + private TeamsAdaptiveColumnSet CreateColumnSet(IEnumerable columns, bool separator = false) { + var columnSet = new TeamsAdaptiveColumnSet { + Separator = separator ? true : null + }; + + foreach (var column in columns) { + columnSet.Columns.Add(column); + } + + return columnSet; + } + + private TeamsAdaptiveColumn CreateColumn(object? value, bool separator, bool header = false) { + var column = new TeamsAdaptiveColumn { + Width = Width.ToLowerInvariant(), + Separator = separator ? true : null + }; + + column.Items.Add(CreateTextBlock(value, header)); + return column; + } + + private TeamsAdaptiveTextBlock CreateTextBlock(object? value, bool header) { + return new TeamsAdaptiveTextBlock { + Text = value?.ToString() ?? string.Empty, + Weight = header ? HeaderWeight : Weight, + Color = header ? HeaderColor : Color, + Wrap = header ? null : (Wrap.IsPresent ? true : null), + Size = header ? HeaderSize : Size, + Highlight = header + ? (HeaderHighlight.IsPresent ? true : null) + : (Highlight ? true : null), + Italic = header + ? (HeaderItalic.IsPresent ? true : null) + : (Italic ? true : null), + StrikeThrough = header + ? (HeaderStrikeThrough.IsPresent ? true : null) + : (StrikeThrough ? true : null), + FontType = header ? HeaderFontType : FontType.FirstOrDefault(), + Spacing = header ? HeaderSpacing : Spacing, + HorizontalAlignment = header ? HeaderHorizontalAlignment : HorizontalAlignment, + Height = header ? HeaderHeight : Height, + MaximumLines = header + ? (HeaderMaximumLines > 0 ? HeaderMaximumLines : null) + : (MaximumLines > 0 ? MaximumLines : null), + Subtle = header + ? (HeaderSubtle.IsPresent ? true : null) + : (Subtle.IsPresent ? true : null) + }; + } +} diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveTextBlock.cs b/TeamsX.PowerShell/CmdletNewAdaptiveTextBlock.cs new file mode 100644 index 0000000..4fc006f --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewAdaptiveTextBlock.cs @@ -0,0 +1,93 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a legacy-named adaptive text block backed by the TeamsX model. +/// +[Cmdlet(VerbsCommon.New, "AdaptiveTextBlock")] +[OutputType(typeof(TeamsAdaptiveTextBlock))] +public sealed class CmdletNewAdaptiveTextBlock : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Dark", "Light", "Good", "Warning", "Attention")] + public string? Color { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Default", "Monospace")] + public string? FontType { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Subtle { get; set; } + + [Parameter(Mandatory = false)] + public int? MaximumLines { get; set; } + + [Alias("FontSize")] + [Parameter(Mandatory = false)] + [ValidateSet("Small", "Default", "Medium", "Large", "ExtraLarge")] + public string? Size { get; set; } + + [Alias("FontWeight")] + [Parameter(Mandatory = false)] + [ValidateSet("Lighter", "Default", "Bolder")] + public string? Weight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Highlight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Italic { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter StrikeThrough { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Wrap { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsAdaptiveTextBlock { + Text = Text == string.Empty ? $"{(char)0x200F}" : Text ?? string.Empty, + Id = Id, + Spacing = Spacing, + HorizontalAlignment = HorizontalAlignment, + Size = Size, + Weight = Weight, + Color = Color, + Height = Height, + FontType = FontType, + Highlight = Highlight.IsPresent ? true : null, + Italic = Italic.IsPresent ? true : null, + StrikeThrough = StrikeThrough.IsPresent ? true : null, + MaximumLines = MaximumLines, + Separator = Separator.IsPresent ? true : null, + Wrap = Wrap.IsPresent ? true : null, + Subtle = Subtle.IsPresent ? true : null, + IsVisible = Hidden.IsPresent ? false : null + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewCardList.cs b/TeamsX.PowerShell/CmdletNewCardList.cs new file mode 100644 index 0000000..67ed440 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewCardList.cs @@ -0,0 +1,141 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates or sends a Teams ListCard payload. +/// +[Cmdlet(VerbsCommon.New, "CardList", SupportsShouldProcess = true)] +[OutputType(typeof(string))] +public sealed class CmdletNewCardList : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public ScriptBlock Content { get; set; } = null!; + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public Uri? Uri { get; set; } + + protected override void ProcessRecord() { + var card = new TeamsListCard { + Title = Title + }; + + foreach (var item in Content.Invoke()) { + ApplyItem(card, item is PSObject psObject ? psObject.BaseObject : item); + } + + var body = TeamsWrapperCardRenderer.Render(card); + if (Uri is null) { + WriteObject(body); + return; + } + + if (!ShouldProcess(Uri.Host, "Send Teams ListCard using IncomingWebhook")) { + return; + } + + SendAttachmentBody(body, Uri); + } + + private void ApplyItem(TeamsListCard card, object? value) { + if (value is null) { + return; + } + + if (value is TeamsCardButton button) { + if (card.Buttons.Count < 6) { + card.Buttons.Add(button); + } else { + WriteWarning("New-CardList - List Cards support only up to 6 buttons."); + } + + return; + } + + if (value is TeamsListCardItem item) { + card.Items.Add(item); + return; + } + + if (value is IDictionary dictionary) { + if (CmdletNewHeroCard.TryCreateCardButton(dictionary, out var fallbackButton)) { + ApplyItem(card, fallbackButton); + return; + } + + if (TryCreateListItem(dictionary, out var fallbackItem)) { + ApplyItem(card, fallbackItem); + } + } + } + + private void SendAttachmentBody(string attachmentBody, Uri uri) { + var client = TeamsPowerShellDeliverySupport.CreateClient(null); + var target = TeamsMessageTarget.ForIncomingWebhook(uri); + var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); + var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-CardList"); + } + + private static bool TryCreateListItem(IDictionary dictionary, out TeamsListCardItem item) { + if (dictionary.Contains("type")) { + TeamsCardButtonActionType? tapType = null; + string? tapValue = null; + string? tapAction = null; + + if (dictionary.Contains("tap") && dictionary["tap"] is IDictionary tapDictionary) { + tapType = ParseTapType(tapDictionary["type"]?.ToString()); + var combinedValue = tapDictionary["value"]?.ToString(); + if (!string.IsNullOrWhiteSpace(combinedValue)) { + var parts = combinedValue!.Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2) { + tapAction = parts[0]; + tapValue = parts[1]; + } else { + tapValue = combinedValue; + } + } + } + + item = new TeamsListCardItem { + Kind = ParseItemKind(dictionary["type"]?.ToString()), + Icon = dictionary.Contains("icon") ? dictionary["icon"]?.ToString() : null, + Title = dictionary.Contains("title") ? dictionary["title"]?.ToString() : null, + SubTitle = dictionary.Contains("subtitle") ? dictionary["subtitle"]?.ToString() : null, + TapAction = tapAction, + TapType = tapType, + TapValue = tapValue + }; + return true; + } + + item = null!; + return false; + } + + private static TeamsCardButtonActionType? ParseTapType(string? type) { + if (string.IsNullOrWhiteSpace(type)) { + return null; + } + + return type switch { + "imBack" => TeamsCardButtonActionType.ImBack, + "file" => TeamsCardButtonActionType.File, + _ => TeamsCardButtonActionType.OpenUrl + }; + } + + private static TeamsListCardItemKind ParseItemKind(string? type) { + return type switch { + "file" => TeamsListCardItemKind.File, + "section" => TeamsListCardItemKind.Section, + "person" => TeamsListCardItemKind.Person, + _ => TeamsListCardItemKind.ResultItem + }; + } +} diff --git a/TeamsX.PowerShell/CmdletNewCardListButton.cs b/TeamsX.PowerShell/CmdletNewCardListButton.cs new file mode 100644 index 0000000..ca5236c --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewCardListButton.cs @@ -0,0 +1,36 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a button for ListCard, HeroCard, and ThumbnailCard payloads. +/// +[Cmdlet(VerbsCommon.New, "CardListButton")] +[OutputType(typeof(TeamsCardButton))] +public sealed class CmdletNewCardListButton : PSCmdlet { + [Parameter(Mandatory = false)] + public TeamsCardButtonActionType Type { get; set; } + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? Value { get; set; } + + [Parameter(Mandatory = false)] + public string? Image { get; set; } + + protected override void ProcessRecord() { + if (!string.IsNullOrWhiteSpace(Image)) { + WriteWarning("Using Image for Buttons while technically supported by Teams, it's not supported by Teams Connectors. Leaving this in place just in case it starts working"); + } + + WriteObject(new TeamsCardButton { + Type = Type, + Title = Title, + Value = Value, + Image = Image + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewCardListItem.cs b/TeamsX.PowerShell/CmdletNewCardListItem.cs new file mode 100644 index 0000000..0ec2a95 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewCardListItem.cs @@ -0,0 +1,45 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates one Teams list-card item. +/// +[Cmdlet(VerbsCommon.New, "CardListItem")] +[OutputType(typeof(TeamsListCardItem))] +public sealed class CmdletNewCardListItem : PSCmdlet { + [Parameter(Mandatory = true)] + public TeamsListCardItemKind Type { get; set; } + + [Parameter(Mandatory = false)] + public string? Icon { get; set; } + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? SubTitle { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("whois", "editOnline")] + public string? TapAction { get; set; } + + [Parameter(Mandatory = false)] + public TeamsCardButtonActionType? TapType { get; set; } + + [Parameter(Mandatory = false)] + public string? TapValue { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsListCardItem { + Kind = Type, + Icon = Icon, + Title = Title, + SubTitle = SubTitle, + TapAction = TapAction, + TapType = TapType, + TapValue = TapValue + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewHeroCard.cs b/TeamsX.PowerShell/CmdletNewHeroCard.cs new file mode 100644 index 0000000..8be202f --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewHeroCard.cs @@ -0,0 +1,156 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates or sends a Teams HeroCard payload. +/// +[Cmdlet(VerbsCommon.New, "HeroCard", SupportsShouldProcess = true)] +[OutputType(typeof(string))] +public sealed class CmdletNewHeroCard : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public ScriptBlock Content { get; set; } = null!; + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? SubTitle { get; set; } + + [Parameter(Mandatory = false)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + public Uri? Uri { get; set; } + + protected override void ProcessRecord() { + var card = new TeamsHeroCard { + Title = Title, + SubTitle = SubTitle, + Text = Text + }; + + foreach (var item in Content.Invoke()) { + ApplyItem(card, item is PSObject psObject ? psObject.BaseObject : item); + } + + var body = TeamsWrapperCardRenderer.Render(card); + if (Uri is null) { + WriteObject(body); + return; + } + + if (!ShouldProcess(Uri.Host, "Send Teams HeroCard using IncomingWebhook")) { + return; + } + + SendAttachmentBody(body, Uri); + } + + private void ApplyItem(TeamsHeroCard card, object? value) { + if (value is null) { + return; + } + + if (value is TeamsCardButton button) { + if (card.Buttons.Count < 3) { + card.Buttons.Add(button); + } else { + WriteWarning("New-HeroCard - Herd Card support only up to 3 buttons."); + } + + return; + } + + if (value is TeamsCardImage image) { + if (card.Images.Count < 2) { + card.Images.Add(image); + } else { + WriteWarning("New-HeroCard - Herd Card support only 1 image."); + } + + return; + } + + if (TryCreateCardImage(value, out var mappedImage)) { + ApplyItem(card, mappedImage); + return; + } + + if (value is IDictionary dictionary) { + if (TryCreateCardButton(dictionary, out var fallbackButton)) { + ApplyItem(card, fallbackButton); + return; + } + + if (TryCreateCardImage(dictionary, out var fallbackImage)) { + ApplyItem(card, fallbackImage); + } + } + } + + private void SendAttachmentBody(string attachmentBody, Uri uri) { + var client = TeamsPowerShellDeliverySupport.CreateClient(null); + var target = TeamsMessageTarget.ForIncomingWebhook(uri); + var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); + var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-HeroCard"); + } + + internal static bool TryCreateCardButton(IDictionary dictionary, out TeamsCardButton button) { + if (dictionary.Contains("title") || dictionary.Contains("value")) { + button = new TeamsCardButton { + Type = ParseButtonType(dictionary["type"]?.ToString()), + Title = dictionary["title"]?.ToString(), + Value = dictionary["value"]?.ToString(), + Image = dictionary["image"]?.ToString() + }; + return true; + } + + button = null!; + return false; + } + + internal static bool TryCreateCardImage(object? value, out TeamsCardImage image) { + if (value is TeamsAdaptiveImage adaptiveImage) { + image = new TeamsCardImage { + Url = adaptiveImage.Url, + Alt = adaptiveImage.AltText + }; + return !string.IsNullOrWhiteSpace(image.Url); + } + + if (value is TeamsCardImage teamsCardImage) { + image = teamsCardImage; + return !string.IsNullOrWhiteSpace(image.Url); + } + + image = null!; + return false; + } + + internal static bool TryCreateCardImage(IDictionary dictionary, out TeamsCardImage image) { + if (dictionary.Contains("url")) { + image = new TeamsCardImage { + Url = dictionary["url"]?.ToString(), + Alt = dictionary.Contains("alt") ? dictionary["alt"]?.ToString() : null + }; + return true; + } + + image = null!; + return false; + } + + private static TeamsCardButtonActionType ParseButtonType(string? type) { + return type switch { + "imBack" => TeamsCardButtonActionType.ImBack, + "file" => TeamsCardButtonActionType.File, + _ => TeamsCardButtonActionType.OpenUrl + }; + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsActivityImage.cs b/TeamsX.PowerShell/CmdletNewTeamsActivityImage.cs new file mode 100644 index 0000000..a148e78 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsActivityImage.cs @@ -0,0 +1,63 @@ +using System.IO; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a typed activity-image directive for connector-card sections. +/// +[Cmdlet(VerbsCommon.New, "TeamsActivityImage", DefaultParameterSetName = "Link")] +[Alias("ActivityImageLink", "TeamsActivityImageLink", "New-TeamsActivityImageLink", "ActivityImage", "TeamsActivityImage")] +[OutputType(typeof(TeamsMessageSectionDirective))] +public sealed class CmdletNewTeamsActivityImage : PSCmdlet { + [Parameter(Mandatory = false, ParameterSetName = "Image")] + [ValidateSet("Add", "Alert", "Cancel", "Check", "Disable", "Download", "Info", "Minus", "Question", "Reload", "None")] + public string? Image { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "Link")] + public string? Link { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = "Path")] + public FileInfo? Path { get; set; } + + protected override void ProcessRecord() { + var value = ResolveValue(); + if (value is null) { + return; + } + + WriteObject(new TeamsMessageSectionDirective { + DirectiveType = TeamsMessageSectionDirectiveType.ActivityImage, + Value = value + }); + } + + private string? ResolveValue() { + if (ParameterSetName == "Path") { + if (Path is null) { + return null; + } + + TeamsPowerShellImageSupport.ValidateImageFile( + Path, + nameof(Path), + "Path is inaccessible or does not exist", + "Path is not a file or file extension is not supported"); + + return TeamsPowerShellImageSupport.ResolveImageFile(Path); + } + + if (ParameterSetName == "Image") { + if (string.Equals(Image, "None", StringComparison.OrdinalIgnoreCase)) { + return null; + } + + return string.IsNullOrWhiteSpace(Image) + ? null + : TeamsPowerShellImageSupport.ResolveBuiltInImage(Image!); + } + + return Link; + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsActivitySubtitle.cs b/TeamsX.PowerShell/CmdletNewTeamsActivitySubtitle.cs new file mode 100644 index 0000000..97604d0 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsActivitySubtitle.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a typed activity-subtitle directive for connector-card sections. +/// +[Cmdlet(VerbsCommon.New, "TeamsActivitySubtitle")] +[Alias("ActivitySubtitle", "TeamsActivitySubtitle")] +[OutputType(typeof(TeamsMessageSectionDirective))] +public sealed class CmdletNewTeamsActivitySubtitle : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Subtitle { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageSectionDirective { + DirectiveType = TeamsMessageSectionDirectiveType.ActivitySubtitle, + Value = Subtitle + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsActivityText.cs b/TeamsX.PowerShell/CmdletNewTeamsActivityText.cs new file mode 100644 index 0000000..caaf122 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsActivityText.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a typed activity-text directive for connector-card sections. +/// +[Cmdlet(VerbsCommon.New, "TeamsActivityText")] +[Alias("ActivityText", "TeamsActivityText")] +[OutputType(typeof(TeamsMessageSectionDirective))] +public sealed class CmdletNewTeamsActivityText : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Text { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageSectionDirective { + DirectiveType = TeamsMessageSectionDirectiveType.ActivityText, + Value = Text + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsActivityTitle.cs b/TeamsX.PowerShell/CmdletNewTeamsActivityTitle.cs new file mode 100644 index 0000000..6c18700 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsActivityTitle.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a typed activity-title directive for connector-card sections. +/// +[Cmdlet(VerbsCommon.New, "TeamsActivityTitle")] +[Alias("ActivityTitle", "TeamsActivityTitle")] +[OutputType(typeof(TeamsMessageSectionDirective))] +public sealed class CmdletNewTeamsActivityTitle : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Title { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageSectionDirective { + DirectiveType = TeamsMessageSectionDirectiveType.ActivityTitle, + Value = Title + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveCard.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveCard.cs index eea0312..d5c94e8 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveCard.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveCard.cs @@ -18,9 +18,76 @@ public sealed class CmdletNewTeamsAdaptiveCard : PSCmdlet { [Parameter(Mandatory = false)] public string Version { get; set; } = "1.2"; + [Parameter(Mandatory = false)] + public string? FallbackText { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public string? Speak { get; set; } + + [Parameter(Mandatory = false)] + public string? Language { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundUrl { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Cover", "RepeatHorizontally", "RepeatVertically", "Repeat")] + public string? BackgroundFillMode { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("left", "center", "right")] + public string? BackgroundHorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? BackgroundVerticalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter FullWidth { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowImageExpand { get; set; } + protected override void ProcessRecord() { var card = new TeamsAdaptiveCard { - Version = Version + Version = Version, + FallbackText = FallbackText, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Speak = Speak, + Language = Language, + VerticalContentAlignment = VerticalContentAlignment, + BackgroundImage = BuildBackgroundImage(), + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement), + AllowImageExpand = AllowImageExpand.IsPresent ? true : null, + FullWidth = FullWidth.IsPresent }; foreach (var element in Body ?? Array.Empty()) { @@ -43,4 +110,32 @@ protected override void ProcessRecord() { WriteObject(card); } + + private Dictionary? BuildBackgroundImage() { + if (string.IsNullOrWhiteSpace(BackgroundUrl) && + string.IsNullOrWhiteSpace(BackgroundFillMode) && + string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment) && + string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + return null; + } + + var backgroundImage = new Dictionary(); + if (!string.IsNullOrWhiteSpace(BackgroundFillMode)) { + backgroundImage["fillMode"] = BackgroundFillMode; + } + + if (!string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment)) { + backgroundImage["horizontalAlignment"] = BackgroundHorizontalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + backgroundImage["verticalAlignment"] = BackgroundVerticalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundUrl)) { + backgroundImage["url"] = BackgroundUrl; + } + + return backgroundImage; + } } diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumn.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumn.cs index 3eb86cf..9889b08 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumn.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumn.cs @@ -9,12 +9,77 @@ public sealed class CmdletNewTeamsAdaptiveColumn : PSCmdlet { [Parameter(Mandatory = false)] public string? Width { get; set; } + [Parameter(Mandatory = false)] + public int WidthInWeight { get; set; } + + [Parameter(Mandatory = false)] + public int WidthInPixels { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Top", "Center", "Bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + [Parameter(Mandatory = false)] public TeamsAdaptiveCardElement[] Items { get; set; } = Array.Empty(); protected override void ProcessRecord() { var column = new TeamsAdaptiveColumn { - Width = Width + Width = ResolveWidth(), + Height = Height, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + HorizontalAlignment = HorizontalAlignment, + VerticalContentAlignment = VerticalContentAlignment, + Spacing = Spacing, + Style = Style, + IsVisible = Hidden.IsPresent ? false : null, + Separator = Separator.IsPresent ? true : null, + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement) }; foreach (var item in Items) { @@ -25,4 +90,16 @@ protected override void ProcessRecord() { WriteObject(column); } + + private string? ResolveWidth() { + if (WidthInWeight > 0) { + return WidthInWeight.ToString(); + } + + if (WidthInPixels > 0) { + return $"{WidthInPixels}px"; + } + + return Width; + } } diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumnSet.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumnSet.cs index 2e36224..088df60 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumnSet.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveColumnSet.cs @@ -9,8 +9,41 @@ public sealed class CmdletNewTeamsAdaptiveColumnSet : PSCmdlet { [Parameter(Mandatory = false)] public TeamsAdaptiveColumn[] Columns { get; set; } = Array.Empty(); + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Bleed { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + protected override void ProcessRecord() { - var columnSet = new TeamsAdaptiveColumnSet(); + var columnSet = new TeamsAdaptiveColumnSet { + Style = Style, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Bleed = Bleed.IsPresent ? true : null, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + HorizontalAlignment = HorizontalAlignment, + Height = Height + }; foreach (var column in Columns) { if (column is not null) { diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveContainer.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveContainer.cs index 69966e4..4b3bac9 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveContainer.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveContainer.cs @@ -9,8 +9,92 @@ public sealed class CmdletNewTeamsAdaptiveContainer : PSCmdlet { [Parameter(Mandatory = false)] public TeamsAdaptiveCardElement[] Items { get; set; } = Array.Empty(); + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Accent", "Default", "Emphasis", "Good", "Warning", "Attention")] + public string? Style { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Bleed { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundUrl { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Cover", "RepeatHorizontally", "RepeatVertically", "Repeat")] + public string? BackgroundFillMode { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("left", "center", "right")] + public string? BackgroundHorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? BackgroundVerticalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Action.Submit", "Action.OpenUrl", "Action.ToggleVisibility")] + public string? SelectAction { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionId { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionUrl { get; set; } + + [Parameter(Mandatory = false)] + public string? SelectActionTitle { get; set; } + + [Parameter(Mandatory = false)] + public string[]? SelectActionTargetElement { get; set; } + protected override void ProcessRecord() { - var container = new TeamsAdaptiveContainer(); + var container = new TeamsAdaptiveContainer { + Id = Id, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + Style = Style, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Bleed = Bleed.IsPresent ? true : null, + VerticalContentAlignment = VerticalContentAlignment, + IsVisible = Hidden.IsPresent ? false : null, + BackgroundImage = BuildBackgroundImage(), + SelectAction = TeamsAdaptiveActionSupport.CreateSelectAction( + SelectAction, + SelectActionId, + SelectActionUrl, + SelectActionTitle, + SelectActionTargetElement) + }; foreach (var item in Items) { if (item is not null) { @@ -20,4 +104,32 @@ protected override void ProcessRecord() { WriteObject(container); } + + private Dictionary? BuildBackgroundImage() { + if (string.IsNullOrWhiteSpace(BackgroundUrl) && + string.IsNullOrWhiteSpace(BackgroundFillMode) && + string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment) && + string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + return null; + } + + var backgroundImage = new Dictionary(); + if (!string.IsNullOrWhiteSpace(BackgroundFillMode)) { + backgroundImage["fillMode"] = BackgroundFillMode; + } + + if (!string.IsNullOrWhiteSpace(BackgroundHorizontalAlignment)) { + backgroundImage["horizontalAlignment"] = BackgroundHorizontalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundVerticalAlignment)) { + backgroundImage["verticalAlignment"] = BackgroundVerticalAlignment; + } + + if (!string.IsNullOrWhiteSpace(BackgroundUrl)) { + backgroundImage["url"] = BackgroundUrl; + } + + return backgroundImage; + } } diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveRichTextBlock.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveRichTextBlock.cs index 5c0887b..ed41668 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveRichTextBlock.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveRichTextBlock.cs @@ -36,8 +36,36 @@ public sealed class CmdletNewTeamsAdaptiveRichTextBlock : PSCmdlet { [Parameter(Mandatory = true, ParameterSetName = "Inline")] public TeamsAdaptiveTextRun[] Inlines { get; set; } = Array.Empty(); + [Parameter(Mandatory = false)] + [ValidateSet("None", "Small", "Default", "Medium", "Large", "ExtraLarge", "Padding")] + public string? Spacing { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Separator { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Left", "Center", "Right")] + public string? HorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Stretch", "Automatic")] + public string? Height { get; set; } + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Hidden { get; set; } + protected override void ProcessRecord() { - var block = new TeamsAdaptiveRichTextBlock(); + var block = new TeamsAdaptiveRichTextBlock { + Id = Id, + Spacing = Spacing, + Separator = Separator.IsPresent ? true : null, + HorizontalAlignment = HorizontalAlignment, + Height = Height, + IsVisible = Hidden.IsPresent ? false : null + }; if (ParameterSetName == "Inline") { foreach (var inline in Inlines ?? Array.Empty()) { diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveShowCardAction.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveShowCardAction.cs new file mode 100644 index 0000000..0f6297e --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveShowCardAction.cs @@ -0,0 +1,114 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsAdaptiveShowCardAction")] +[OutputType(typeof(TeamsAdaptiveShowCardAction))] +public sealed class CmdletNewTeamsAdaptiveShowCardAction : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public string Title { get; set; } = string.Empty; + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + [Parameter(Mandatory = false)] + public TeamsAdaptiveCard? Card { get; set; } + + [Parameter(Mandatory = false)] + public TeamsAdaptiveCardElement[] Body { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public TeamsAdaptiveAction[] Actions { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public TeamsAdaptiveMention[] Mentions { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public string Version { get; set; } = "1.2"; + + [Parameter(Mandatory = false)] + public string? FallbackText { get; set; } + + [Parameter(Mandatory = false)] + public int MinimumHeight { get; set; } + + [Parameter(Mandatory = false)] + public string? Speak { get; set; } + + [Parameter(Mandatory = false)] + public string? Language { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? VerticalContentAlignment { get; set; } + + [Parameter(Mandatory = false)] + public string? BackgroundUrl { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Cover", "RepeatHorizontally", "RepeatVertically", "Repeat")] + public string? BackgroundFillMode { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("left", "center", "right")] + public string? BackgroundHorizontalAlignment { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("top", "center", "bottom")] + public string? BackgroundVerticalAlignment { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter FullWidth { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowImageExpand { get; set; } + + protected override void ProcessRecord() { + var nestedCard = Card ?? BuildCardFromParameters(); + + WriteObject(new TeamsAdaptiveShowCardAction { + Id = Id, + Title = Title, + Card = TeamsAdaptiveCardDictionarySupport.ToDictionary(nestedCard) + }); + } + + private TeamsAdaptiveCard BuildCardFromParameters() { + var card = new TeamsAdaptiveCard { + Version = Version, + FallbackText = FallbackText, + MinimumHeight = MinimumHeight > 0 ? $"{MinimumHeight}px" : null, + Speak = Speak, + Language = Language, + VerticalContentAlignment = VerticalContentAlignment, + BackgroundImage = TeamsAdaptiveCardDictionarySupport.BuildBackgroundImage( + BackgroundUrl, + BackgroundFillMode, + BackgroundHorizontalAlignment, + BackgroundVerticalAlignment), + AllowImageExpand = AllowImageExpand.IsPresent ? true : null, + FullWidth = FullWidth.IsPresent + }; + + foreach (var element in Body ?? Array.Empty()) { + if (element is not null) { + card.Body.Add(element); + } + } + + foreach (var action in Actions ?? Array.Empty()) { + if (action is not null) { + card.Actions.Add(action); + } + } + + foreach (var mention in Mentions ?? Array.Empty()) { + if (mention is not null) { + card.Mentions.Add(mention); + } + } + + return card; + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsAdaptiveSubmitAction.cs b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveSubmitAction.cs new file mode 100644 index 0000000..1b1ec2b --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsAdaptiveSubmitAction.cs @@ -0,0 +1,21 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsAdaptiveSubmitAction")] +[OutputType(typeof(TeamsAdaptiveSubmitAction))] +public sealed class CmdletNewTeamsAdaptiveSubmitAction : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public string Title { get; set; } = string.Empty; + + [Parameter(Mandatory = false)] + public string? Id { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsAdaptiveSubmitAction { + Id = Id, + Title = Title + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsBigImage.cs b/TeamsX.PowerShell/CmdletNewTeamsBigImage.cs new file mode 100644 index 0000000..1db1dff --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsBigImage.cs @@ -0,0 +1,30 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a hero-style markdown image entry for section text. +/// +[Cmdlet(VerbsCommon.New, "TeamsBigImage")] +[Alias("TeamsBigImage")] +[OutputType(typeof(TeamsMessageImage))] +public sealed class CmdletNewTeamsBigImage : PSCmdlet { + [Alias("Url", "Uri")] + [Parameter(Mandatory = false, Position = 0)] + public string? Link { get; set; } + + [Parameter(Mandatory = false)] + public string AlternativeText { get; set; } = "Alternative Text"; + + protected override void ProcessRecord() { + if (string.IsNullOrWhiteSpace(Link)) { + return; + } + + WriteObject(new TeamsMessageImage { + Image = $"![{AlternativeText}]({Link})", + IsHeroImage = true + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsButton.cs b/TeamsX.PowerShell/CmdletNewTeamsButton.cs new file mode 100644 index 0000000..c377987 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsButton.cs @@ -0,0 +1,36 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a connector-card button/action. +/// +[Cmdlet(VerbsCommon.New, "TeamsButton")] +[Alias("TeamsButton")] +[OutputType(typeof(TeamsMessageButton))] +public sealed class CmdletNewTeamsButton : PSCmdlet { + [Alias("ButtonName")] + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNull] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = null!; + + [Alias("TargetUri", "Uri", "Url")] + [Parameter(Mandatory = true, Position = 1)] + [ValidateNotNull] + [ValidateNotNullOrEmpty] + public string Link { get; set; } = null!; + + [Alias("ButtonType")] + [Parameter(Mandatory = false)] + public TeamsMessageButtonType Type { get; set; } = TeamsMessageButtonType.ViewAction; + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageButton { + Name = Name, + Link = Link, + ButtonType = Type + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsCardImage.cs b/TeamsX.PowerShell/CmdletNewTeamsCardImage.cs new file mode 100644 index 0000000..9961e79 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsCardImage.cs @@ -0,0 +1,30 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates an image entry for HeroCard or ThumbnailCard content. +/// +[Cmdlet(VerbsCommon.New, "TeamsCardImage")] +[OutputType(typeof(TeamsCardImage))] +public sealed class CmdletNewTeamsCardImage : PSCmdlet { + [Alias("Link")] + [Parameter(Mandatory = false, Position = 0)] + public string? Url { get; set; } + + [Alias("AltText", "Alt")] + [Parameter(Mandatory = false)] + public string? AlternateText { get; set; } + + protected override void ProcessRecord() { + if (string.IsNullOrWhiteSpace(Url)) { + return; + } + + WriteObject(new TeamsCardImage { + Url = Url, + Alt = AlternateText + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsFact.cs b/TeamsX.PowerShell/CmdletNewTeamsFact.cs new file mode 100644 index 0000000..138fccc --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsFact.cs @@ -0,0 +1,25 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a connector-card fact item. +/// +[Cmdlet(VerbsCommon.New, "TeamsFact")] +[Alias("TeamsFact")] +[OutputType(typeof(TeamsMessageFact))] +public sealed class CmdletNewTeamsFact : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Name { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public string? Value { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageFact { + Name = Name, + Value = Value + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsGraphTarget.cs b/TeamsX.PowerShell/CmdletNewTeamsGraphTarget.cs new file mode 100644 index 0000000..f1f5702 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsGraphTarget.cs @@ -0,0 +1,77 @@ +using System.Management.Automation; +using System.Security; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsGraphTarget")] +[OutputType(typeof(TeamsMessageTarget))] +public sealed class CmdletNewTeamsGraphTarget : PSCmdlet { + [Parameter(Mandatory = true, ParameterSetName = "ChannelToken")] + [Parameter(Mandatory = true, ParameterSetName = "ChannelSecure")] + [Parameter(Mandatory = true, ParameterSetName = "ChannelVariable")] + public string TeamId { get; set; } = null!; + + [Parameter(Mandatory = true, ParameterSetName = "ChannelToken")] + [Parameter(Mandatory = true, ParameterSetName = "ChannelSecure")] + [Parameter(Mandatory = true, ParameterSetName = "ChannelVariable")] + public string ChannelId { get; set; } = null!; + + [Parameter(Mandatory = true, ParameterSetName = "ChatToken")] + [Parameter(Mandatory = true, ParameterSetName = "ChatSecure")] + [Parameter(Mandatory = true, ParameterSetName = "ChatVariable")] + public string ChatId { get; set; } = null!; + + [Alias("Token")] + [Parameter(Mandatory = true, ParameterSetName = "ChannelToken")] + [Parameter(Mandatory = true, ParameterSetName = "ChatToken")] + public string AccessToken { get; set; } = null!; + + [Parameter(Mandatory = true, ParameterSetName = "ChannelSecure")] + [Parameter(Mandatory = true, ParameterSetName = "ChatSecure")] + public SecureString SecureAccessToken { get; set; } = null!; + + [Parameter(Mandatory = true, ParameterSetName = "ChannelVariable")] + [Parameter(Mandatory = true, ParameterSetName = "ChatVariable")] + public string AccessTokenVariableName { get; set; } = null!; + + [Parameter(Mandatory = false, ParameterSetName = "ChannelToken")] + [Parameter(Mandatory = false, ParameterSetName = "ChannelSecure")] + [Parameter(Mandatory = false, ParameterSetName = "ChannelVariable")] + [Parameter(Mandatory = false, ParameterSetName = "ChatToken")] + [Parameter(Mandatory = false, ParameterSetName = "ChatSecure")] + [Parameter(Mandatory = false, ParameterSetName = "ChatVariable")] + public string? DisplayName { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "ChannelToken")] + [Parameter(Mandatory = false, ParameterSetName = "ChannelSecure")] + [Parameter(Mandatory = false, ParameterSetName = "ChannelVariable")] + [Parameter(Mandatory = false, ParameterSetName = "ChatToken")] + [Parameter(Mandatory = false, ParameterSetName = "ChatSecure")] + [Parameter(Mandatory = false, ParameterSetName = "ChatVariable")] + public Uri? GraphBaseUri { get; set; } + + protected override void ProcessRecord() { + var accessTokenProvider = ResolveAccessTokenProvider(); + + var target = ParameterSetName switch { + "ChannelToken" => TeamsMessageTarget.ForGraphChannelMessage(TeamId, ChannelId, AccessToken, DisplayName, GraphBaseUri), + "ChannelSecure" => TeamsMessageTarget.ForGraphChannelMessage(TeamId, ChannelId, accessTokenProvider!, DisplayName, GraphBaseUri), + "ChannelVariable" => TeamsMessageTarget.ForGraphChannelMessage(TeamId, ChannelId, accessTokenProvider!, DisplayName, GraphBaseUri), + "ChatToken" => TeamsMessageTarget.ForGraphChatMessage(ChatId, AccessToken, DisplayName, GraphBaseUri), + "ChatSecure" => TeamsMessageTarget.ForGraphChatMessage(ChatId, accessTokenProvider!, DisplayName, GraphBaseUri), + "ChatVariable" => TeamsMessageTarget.ForGraphChatMessage(ChatId, accessTokenProvider!, DisplayName, GraphBaseUri), + _ => throw new InvalidOperationException($"Unsupported parameter set '{ParameterSetName}'.") + }; + + WriteObject(target); + } + + private Func>? ResolveAccessTokenProvider() { + return ParameterSetName switch { + "ChannelSecure" or "ChatSecure" => _ => Task.FromResult(TeamsPowerShellGraphTokenSupport.ConvertToUnsecureString(SecureAccessToken)), + "ChannelVariable" or "ChatVariable" => _ => Task.FromResult(TeamsPowerShellGraphTokenSupport.ReadEnvironmentVariable(AccessTokenVariableName)), + _ => null + }; + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsHeroCard.cs b/TeamsX.PowerShell/CmdletNewTeamsHeroCard.cs new file mode 100644 index 0000000..e420889 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsHeroCard.cs @@ -0,0 +1,45 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsHeroCard")] +[OutputType(typeof(TeamsHeroCard))] +public sealed class CmdletNewTeamsHeroCard : PSCmdlet { + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? SubTitle { get; set; } + + [Parameter(Mandatory = false)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + public TeamsCardImage[] Images { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public TeamsCardButton[] Buttons { get; set; } = Array.Empty(); + + protected override void ProcessRecord() { + var card = new TeamsHeroCard { + Title = Title, + SubTitle = SubTitle, + Text = Text + }; + + foreach (var image in Images ?? Array.Empty()) { + if (image is not null) { + card.Images.Add(image); + } + } + + foreach (var button in Buttons ?? Array.Empty()) { + if (button is not null) { + card.Buttons.Add(button); + } + } + + WriteObject(card); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsImage.cs b/TeamsX.PowerShell/CmdletNewTeamsImage.cs new file mode 100644 index 0000000..6b9f0cd --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsImage.cs @@ -0,0 +1,27 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a standard section image entry. +/// +[Cmdlet(VerbsCommon.New, "TeamsImage")] +[Alias("TeamsImage")] +[OutputType(typeof(TeamsMessageImage))] +public sealed class CmdletNewTeamsImage : PSCmdlet { + [Alias("Url", "Uri")] + [Parameter(Mandatory = false, Position = 0)] + public string? Link { get; set; } + + protected override void ProcessRecord() { + if (string.IsNullOrWhiteSpace(Link)) { + return; + } + + WriteObject(new TeamsMessageImage { + Image = Link, + IsHeroImage = false + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsList.cs b/TeamsX.PowerShell/CmdletNewTeamsList.cs new file mode 100644 index 0000000..02c941a --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsList.cs @@ -0,0 +1,93 @@ +using System.Collections; +using System.Globalization; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Builds a legacy list fact from typed list items. +/// +[Cmdlet(VerbsCommon.New, "TeamsList")] +[Alias("TeamsList")] +[OutputType(typeof(TeamsMessageFact))] +public sealed class CmdletNewTeamsList : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? List { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public string? Name { get; set; } + + protected override void ProcessRecord() { + if (List is null) { + return; + } + + var lines = new List(); + foreach (var value in List.Invoke()) { + ApplyListItem(lines, value); + } + + WriteObject(new TeamsMessageFact { + Name = Name, + Value = string.Join("\r", lines) + }); + } + + private static void ApplyListItem(ICollection lines, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsMessageListItem listItem) { + lines.Add(RenderListItem(listItem)); + return; + } + + if (value is IDictionary dictionary && TryCreateListItem(dictionary, out var converted)) { + lines.Add(RenderListItem(converted)); + } + } + + private static string RenderListItem(TeamsMessageListItem item) { + var marker = item.Numbered ? "1. " : "- "; + var indent = item.Level > 0 ? new string('\t', item.Level) : string.Empty; + return string.Concat(indent, marker, item.Text ?? string.Empty); + } + + private static bool TryCreateListItem(IDictionary dictionary, out TeamsMessageListItem item) { + item = null!; + var type = GetDictionaryString(dictionary, "Type"); + if (!string.Equals(type, "ListItem", StringComparison.OrdinalIgnoreCase)) { + return false; + } + + item = new TeamsMessageListItem { + Text = GetDictionaryString(dictionary, "Text"), + Level = GetDictionaryInt32(dictionary, "Level"), + Numbered = GetDictionaryBoolean(dictionary, "Numbered") + }; + return true; + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } + + private static int GetDictionaryInt32(IDictionary dictionary, string key) { + var value = GetDictionaryString(dictionary, key); + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : 0; + } + + private static bool GetDictionaryBoolean(IDictionary dictionary, string key) { + var value = GetDictionaryString(dictionary, key); + return bool.TryParse(value, out var parsed) && parsed; + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsListCard.cs b/TeamsX.PowerShell/CmdletNewTeamsListCard.cs new file mode 100644 index 0000000..5d04015 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsListCard.cs @@ -0,0 +1,37 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsListCard")] +[OutputType(typeof(TeamsListCard))] +public sealed class CmdletNewTeamsListCard : PSCmdlet { + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public TeamsListCardItem[] Items { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public TeamsCardButton[] Buttons { get; set; } = Array.Empty(); + + protected override void ProcessRecord() { + var card = new TeamsListCard { + Title = Title + }; + + foreach (var item in Items ?? Array.Empty()) { + if (item is not null) { + card.Items.Add(item); + } + } + + foreach (var button in Buttons ?? Array.Empty()) { + if (button is not null) { + card.Buttons.Add(button); + } + } + + WriteObject(card); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsListItem.cs b/TeamsX.PowerShell/CmdletNewTeamsListItem.cs new file mode 100644 index 0000000..c5bf824 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsListItem.cs @@ -0,0 +1,29 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a typed legacy list item for connector-card facts. +/// +[Cmdlet(VerbsCommon.New, "TeamsListItem")] +[Alias("TeamsListItem")] +[OutputType(typeof(TeamsMessageListItem))] +public sealed class CmdletNewTeamsListItem : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public string? Text { get; set; } + + [Parameter(Mandatory = false, Position = 1)] + public int Level { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Numbered { get; set; } + + protected override void ProcessRecord() { + WriteObject(new TeamsMessageListItem { + Text = Text, + Level = Level, + Numbered = Numbered.IsPresent + }); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs index 17d8479..14ca619 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs @@ -18,12 +18,44 @@ public sealed class CmdletNewTeamsMessage : PSCmdlet { [Parameter(Mandatory = false)] public TeamsAdaptiveCard? AdaptiveCard { get; set; } + [Parameter(Mandatory = false)] + public TeamsMessageSection[] Sections { get; set; } = Array.Empty(); + + [Alias("Color")] + [Parameter(Mandatory = false)] + public string? ThemeColor { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter HideOriginalBody { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter UseConnectorCardFormat { get; set; } + protected override void ProcessRecord() { - WriteObject(new TeamsMessageRequest { + var request = new TeamsMessageRequest { Title = Title, Text = Text, Summary = Summary, - AdaptiveCard = AdaptiveCard - }); + AdaptiveCard = AdaptiveCard, + ThemeColor = ResolveThemeColor(), + HideOriginalBody = HideOriginalBody.IsPresent, + UseConnectorCardFormat = UseConnectorCardFormat.IsPresent || (AdaptiveCard is null && Sections.Length > 0) + }; + + foreach (var section in Sections) { + if (section is not null) { + request.Sections.Add(section); + } + } + + WriteObject(request); + } + + private string? ResolveThemeColor() { + if (string.IsNullOrWhiteSpace(ThemeColor)) { + return null; + } + + return TeamsColorUtility.NormalizeToHex(ThemeColor); } } diff --git a/TeamsX.PowerShell/CmdletNewTeamsSection.cs b/TeamsX.PowerShell/CmdletNewTeamsSection.cs new file mode 100644 index 0000000..4f78e6f --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsSection.cs @@ -0,0 +1,250 @@ +using System.Collections; +using System.IO; +using System.Linq; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates a connector-card section. +/// +[Cmdlet(VerbsCommon.New, "TeamsSection")] +[Alias("TeamsSection")] +[OutputType(typeof(TeamsMessageSection))] +public sealed class CmdletNewTeamsSection : PSCmdlet { + [Parameter(Mandatory = false, Position = 0)] + public ScriptBlock? SectionInput { get; set; } + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? ActivityTitle { get; set; } + + [Parameter(Mandatory = false)] + public string? ActivitySubtitle { get; set; } + + [Parameter(Mandatory = false)] + public string? ActivityImageLink { get; set; } + + [Parameter(Mandatory = false)] + [ValidateSet("Alert", "Cancel", "Disable", "Download", "Minus", "Check", "Add", "None")] + public string ActivityImage { get; set; } = "None"; + + [Parameter(Mandatory = false)] + public FileInfo? ActivityImagePath { get; set; } + + [Parameter(Mandatory = false)] + public string? ActivityText { get; set; } + + [Parameter(Mandatory = false)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + public TeamsMessageFact[]? ActivityDetails { get; set; } + + [Parameter(Mandatory = false)] + public TeamsMessageButton[]? Buttons { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter StartGroup { get; set; } + + protected override void ProcessRecord() { + var section = new TeamsMessageSection { + Title = Title, + ActivityTitle = ActivityTitle, + ActivitySubtitle = ActivitySubtitle, + ActivityText = ActivityText, + Text = Text, + StartGroup = StartGroup.IsPresent + }; + + if (ActivityImagePath is not null) { + ValidateActivityImagePath(ActivityImagePath); + section.ActivityImage = TeamsImageDataUtility.FromFile(ActivityImagePath.FullName); + } else if (!string.Equals(ActivityImage, "None", StringComparison.OrdinalIgnoreCase)) { + section.ActivityImage = ResolveBuiltInImage(ActivityImage); + } else if (!string.IsNullOrWhiteSpace(ActivityImageLink)) { + section.ActivityImage = ActivityImageLink; + } + + if (ActivityDetails is not null) { + foreach (var fact in ActivityDetails) { + section.Facts.Add(fact); + } + } + + if (Buttons is not null) { + foreach (var button in Buttons) { + section.Buttons.Add(button); + } + } + + if (SectionInput is not null) { + foreach (var item in SectionInput.Invoke()) { + ApplySectionItem(section, item); + } + } + + WriteObject(section); + } + + private void ApplySectionItem(TeamsMessageSection section, object? input) { + var value = input is PSObject psObject ? psObject.BaseObject : input; + if (value is null) { + return; + } + + if (value is TeamsMessageButton button) { + section.Buttons.Add(button); + return; + } + + if (value is TeamsMessageFact fact) { + section.Facts.Add(fact); + return; + } + + if (value is TeamsMessageImage image) { + AddMessageImage(section, image); + return; + } + + if (value is TeamsMessageSectionDirective directive) { + ApplyDirective(section, directive); + return; + } + + if (value is IDictionary dictionary) { + var markerType = GetDictionaryString(dictionary, "Type"); + switch (markerType) { + case "button": + section.Buttons.Add(new TeamsMessageButton { + Name = GetDictionaryString(dictionary, "name") ?? GetDictionaryString(dictionary, "Name"), + Link = GetButtonLink(dictionary), + ButtonType = ParseButtonType(GetDictionaryString(dictionary, "@type")) + }); + return; + case "fact": + section.Facts.Add(new TeamsMessageFact { + Name = GetDictionaryString(dictionary, "name"), + Value = GetDictionaryString(dictionary, "value") + }); + return; + case "image": + var imageValue = GetDictionaryString(dictionary, "image"); + if (!string.IsNullOrWhiteSpace(imageValue)) { + section.Images.Add(imageValue!); + } + return; + case "HeroImageWorkaround": + var heroImage = GetDictionaryString(dictionary, "image"); + if (!string.IsNullOrWhiteSpace(heroImage)) { + section.HeroImages.Add(heroImage!); + } + return; + case "ActivityTitle": + section.ActivityTitle = GetDictionaryString(dictionary, "ActivityTitle"); + return; + case "ActivitySubtitle": + section.ActivitySubtitle = GetDictionaryString(dictionary, "ActivitySubtitle"); + return; + case "ActivityText": + section.ActivityText = GetDictionaryString(dictionary, "ActivityText"); + return; + case "ActivityImage": + case "ActivityImageLink": + section.ActivityImage = GetDictionaryString(dictionary, "ActivityImageLink"); + return; + } + } + } + + private static void ApplyDirective(TeamsMessageSection section, TeamsMessageSectionDirective directive) { + switch (directive.DirectiveType) { + case TeamsMessageSectionDirectiveType.ActivityTitle: + section.ActivityTitle = directive.Value; + return; + case TeamsMessageSectionDirectiveType.ActivitySubtitle: + section.ActivitySubtitle = directive.Value; + return; + case TeamsMessageSectionDirectiveType.ActivityText: + section.ActivityText = directive.Value; + return; + case TeamsMessageSectionDirectiveType.ActivityImage: + section.ActivityImage = directive.Value; + return; + } + } + + private static void AddMessageImage(TeamsMessageSection section, TeamsMessageImage image) { + if (string.IsNullOrWhiteSpace(image.Image)) { + return; + } + + if (image.IsHeroImage) { + section.HeroImages.Add(image.Image!); + return; + } + + section.Images.Add(image.Image!); + } + + private static string? GetDictionaryString(IDictionary dictionary, string key) { + if (!dictionary.Contains(key)) { + return null; + } + + return dictionary[key]?.ToString(); + } + + private static string? GetButtonLink(IDictionary dictionary) { + if (dictionary.Contains("target")) { + var targetValue = dictionary["target"]; + if (targetValue is IEnumerable enumerable && targetValue is not string) { + foreach (var entry in enumerable) { + return entry?.ToString(); + } + } + } + + if (dictionary.Contains("Target")) { + return dictionary["Target"]?.ToString(); + } + + if (dictionary.Contains("Targets")) { + var targetsValue = dictionary["Targets"]; + if (targetsValue is IEnumerable targets && targetsValue is not string) { + foreach (var entry in targets) { + if (entry is IDictionary targetDictionary && targetDictionary.Contains("uri")) { + return targetDictionary["uri"]?.ToString(); + } + } + } + } + + return null; + } + + private static TeamsMessageButtonType ParseButtonType(string? payloadType) { + return payloadType switch { + "ActionCard" => TeamsMessageButtonType.TextInput, + "HttpPOST" => TeamsMessageButtonType.HttpPost, + "OpenURI" => TeamsMessageButtonType.OpenUri, + _ => TeamsMessageButtonType.ViewAction + }; + } + + private static void ValidateActivityImagePath(FileInfo path) { + TeamsPowerShellImageSupport.ValidateImageFile( + path, + nameof(path), + "ActivityImagePath is inaccessible or does not exist", + "ActivityImagePath is not a file or file extension is not supported"); + } + + private static string ResolveBuiltInImage(string imageName) { + return TeamsPowerShellImageSupport.ResolveBuiltInImage(imageName); + } +} diff --git a/TeamsX.PowerShell/CmdletNewTeamsThumbnailCard.cs b/TeamsX.PowerShell/CmdletNewTeamsThumbnailCard.cs new file mode 100644 index 0000000..d81acf3 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewTeamsThumbnailCard.cs @@ -0,0 +1,45 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +[Cmdlet(VerbsCommon.New, "TeamsThumbnailCard")] +[OutputType(typeof(TeamsThumbnailCard))] +public sealed class CmdletNewTeamsThumbnailCard : PSCmdlet { + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? SubTitle { get; set; } + + [Parameter(Mandatory = false)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + public TeamsCardImage[] Images { get; set; } = Array.Empty(); + + [Parameter(Mandatory = false)] + public TeamsCardButton[] Buttons { get; set; } = Array.Empty(); + + protected override void ProcessRecord() { + var card = new TeamsThumbnailCard { + Title = Title, + SubTitle = SubTitle, + Text = Text + }; + + foreach (var image in Images ?? Array.Empty()) { + if (image is not null) { + card.Images.Add(image); + } + } + + foreach (var button in Buttons ?? Array.Empty()) { + if (button is not null) { + card.Buttons.Add(button); + } + } + + WriteObject(card); + } +} diff --git a/TeamsX.PowerShell/CmdletNewThumbnailCard.cs b/TeamsX.PowerShell/CmdletNewThumbnailCard.cs new file mode 100644 index 0000000..58fcc28 --- /dev/null +++ b/TeamsX.PowerShell/CmdletNewThumbnailCard.cs @@ -0,0 +1,102 @@ +using System.Collections; +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Creates or sends a Teams ThumbnailCard payload. +/// +[Cmdlet(VerbsCommon.New, "ThumbnailCard", SupportsShouldProcess = true)] +[OutputType(typeof(string))] +public sealed class CmdletNewThumbnailCard : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public ScriptBlock Content { get; set; } = null!; + + [Parameter(Mandatory = false)] + public string? Title { get; set; } + + [Parameter(Mandatory = false)] + public string? SubTitle { get; set; } + + [Parameter(Mandatory = false)] + public string? Text { get; set; } + + [Parameter(Mandatory = false)] + public Uri? Uri { get; set; } + + protected override void ProcessRecord() { + var card = new TeamsThumbnailCard { + Title = Title, + SubTitle = SubTitle, + Text = Text + }; + + foreach (var item in Content.Invoke()) { + ApplyItem(card, item is PSObject psObject ? psObject.BaseObject : item); + } + + var body = TeamsWrapperCardRenderer.Render(card); + if (Uri is null) { + WriteObject(body); + return; + } + + if (!ShouldProcess(Uri.Host, "Send Teams ThumbnailCard using IncomingWebhook")) { + return; + } + + SendAttachmentBody(body, Uri); + } + + private void ApplyItem(TeamsThumbnailCard card, object? value) { + if (value is null) { + return; + } + + if (value is TeamsCardButton button) { + if (card.Buttons.Count < 6) { + card.Buttons.Add(button); + } else { + WriteWarning("New-ThumbnailCard - Thumbnail Card support only up to 6 buttons."); + } + + return; + } + + if (value is TeamsCardImage image) { + if (card.Images.Count < 1) { + card.Images.Add(image); + } else { + WriteWarning("New-ThumbnailCard - Thumbnail Card support only 1 image."); + } + + return; + } + + if (CmdletNewHeroCard.TryCreateCardImage(value, out var mappedImage)) { + ApplyItem(card, mappedImage); + return; + } + + if (value is IDictionary dictionary) { + if (CmdletNewHeroCard.TryCreateCardButton(dictionary, out var fallbackButton)) { + ApplyItem(card, fallbackButton); + return; + } + + if (CmdletNewHeroCard.TryCreateCardImage(dictionary, out var fallbackImage)) { + ApplyItem(card, fallbackImage); + } + } + } + + private void SendAttachmentBody(string attachmentBody, Uri uri) { + var client = TeamsPowerShellDeliverySupport.CreateClient(null); + var target = TeamsMessageTarget.ForIncomingWebhook(uri); + var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); + var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-ThumbnailCard"); + } +} diff --git a/TeamsX.PowerShell/CmdletSendTeamsMessage.cs b/TeamsX.PowerShell/CmdletSendTeamsMessage.cs index 61ea79c..6b3b88e 100644 --- a/TeamsX.PowerShell/CmdletSendTeamsMessage.cs +++ b/TeamsX.PowerShell/CmdletSendTeamsMessage.cs @@ -4,32 +4,97 @@ namespace TeamsX.PowerShell; [Cmdlet(VerbsCommunications.Send, "TeamsMessage", SupportsShouldProcess = true)] -[OutputType(typeof(TeamsDeliveryResult))] +[Alias("TeamsMessage")] +[OutputType(typeof(TeamsDeliveryResult), typeof(string))] public sealed class CmdletSendTeamsMessage : PSCmdlet { - private static readonly TeamsClient SharedClient = TeamsClient.Default; - - [Parameter(Mandatory = true, Position = 0)] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "TypedMessage")] public TeamsMessageRequest Message { get; set; } = null!; - [Parameter(Mandatory = true, Position = 1)] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "TypedHeroCard")] + public TeamsHeroCard HeroCard { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "TypedThumbnailCard")] + public TeamsThumbnailCard ThumbnailCard { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "TypedListCard")] + public TeamsListCard ListCard { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "TypedMessage")] + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "TypedHeroCard")] + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "TypedThumbnailCard")] + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "TypedListCard")] public TeamsMessageTarget Target { get; set; } = null!; - [Parameter(Mandatory = false)] + [Parameter(Mandatory = false, ParameterSetName = "TypedMessage")] + [Parameter(Mandatory = false, ParameterSetName = "TypedHeroCard")] + [Parameter(Mandatory = false, ParameterSetName = "TypedThumbnailCard")] + [Parameter(Mandatory = false, ParameterSetName = "TypedListCard")] public SwitchParameter PassThru { get; set; } + [Parameter(Mandatory = false, Position = 0, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, Position = 0, ParameterSetName = "LegacySections")] + public ScriptBlock? SectionsInput { get; set; } + + [Alias("TeamsID", "Url")] + [Parameter(Mandatory = true, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = true, ParameterSetName = "LegacySections")] + public Uri Uri { get; set; } = null!; + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public string? MessageTitle { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public string? MessageText { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public string? MessageSummary { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public string? Color { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public SwitchParameter HideOriginalBody { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public Uri? Proxy { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = "LegacySections")] + public TeamsMessageSection[] Sections { get; set; } = Array.Empty(); + + [Alias("Supress")] + [Parameter(Mandatory = false, ParameterSetName = "LegacyScript")] + [Parameter(Mandatory = false, ParameterSetName = "LegacySections")] + public bool Suppress { get; set; } = true; + protected override void ProcessRecord() { - ProcessTypedRecord(); + if (ParameterSetName.StartsWith("Typed", StringComparison.Ordinal)) { + ProcessTypedRecord(); + return; + } + + ProcessLegacyRecord(); } private void ProcessTypedRecord() { - if (!ShouldProcess(GetShouldProcessTarget(), $"Send Teams message using {Target.DeliveryMethod}")) { + if (!ShouldProcess(GetShouldProcessTarget(), $"Send {GetTypedPayloadName()} using {Target.DeliveryMethod}")) { return; } - var result = SharedClient.SendAsync(Message, Target).GetAwaiter().GetResult(); + var result = ParameterSetName switch { + "TypedHeroCard" => TeamsClient.Default.SendAsync(HeroCard, Target).GetAwaiter().GetResult(), + "TypedThumbnailCard" => TeamsClient.Default.SendAsync(ThumbnailCard, Target).GetAwaiter().GetResult(), + "TypedListCard" => TeamsClient.Default.SendAsync(ListCard, Target).GetAwaiter().GetResult(), + _ => TeamsClient.Default.SendAsync(Message, Target).GetAwaiter().GetResult() + }; if (!result.IsSuccessStatusCode) { - WriteError(CreateDeliveryFailureError(result)); + WriteError(TeamsPowerShellDeliverySupport.CreateDeliveryFailureError(result, "Send-TeamsMessage")); } if (PassThru) { @@ -37,6 +102,73 @@ private void ProcessTypedRecord() { } } + private void ProcessLegacyRecord() { + var request = new TeamsMessageRequest { + Title = MessageTitle, + Text = MessageText, + Summary = MessageSummary, + ThemeColor = ResolveThemeColor(), + HideOriginalBody = HideOriginalBody.IsPresent, + UseConnectorCardFormat = true + }; + + foreach (var section in ResolveLegacySections()) { + request.Sections.Add(section); + } + + var renderedBody = WebhookMessageRenderer.Render(request); + WriteVerbose($"Send-TeamsMessage - Body {renderedBody}"); + + if (!ShouldProcess(Uri.Host, "Send Teams message using IncomingWebhook")) { + if (!Suppress) { + WriteObject(renderedBody); + } + + return; + } + + var client = TeamsPowerShellDeliverySupport.CreateClient(Proxy); + var target = TeamsMessageTarget.ForIncomingWebhook(Uri); + var result = client.SendAsync(request, target).GetAwaiter().GetResult(); + + WriteVerbose($"Send-TeamsMessage - Execute {result.ResponseBody}"); + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "Send-TeamsMessage"); + + if (!Suppress) { + WriteObject(renderedBody); + } + } + + private IEnumerable ResolveLegacySections() { + if (string.Equals(ParameterSetName, "LegacySections", StringComparison.Ordinal)) { + return Sections ?? Array.Empty(); + } + + if (SectionsInput is null) { + return Array.Empty(); + } + + return SectionsInput + .Invoke() + .Select(item => item?.BaseObject) + .OfType() + .ToArray(); + } + + private string? ResolveThemeColor() { + if (string.IsNullOrWhiteSpace(Color)) { + return null; + } + + try { + return TeamsColorUtility.NormalizeToHex(Color); + } catch (ArgumentException exception) { + var errorMessage = exception.Message.Replace(Environment.NewLine, " "); + WriteWarning($"Send-TeamsMessage - Color conversion for {Color} failed. Error message: {errorMessage}"); + return null; + } + } + private string GetShouldProcessTarget() { if (!string.IsNullOrWhiteSpace(Target.DisplayName)) { return Target.DisplayName!; @@ -45,17 +177,12 @@ private string GetShouldProcessTarget() { return $"{Target.DeliveryMethod} target at {Target.TargetUri.Host}"; } - private ErrorRecord CreateDeliveryFailureError(TeamsDeliveryResult result) { - var statusCode = result.StatusCode?.ToString() ?? "unknown"; - var message = $"Teams message delivery failed using {result.DeliveryMethod}. HTTP status: {statusCode}."; - var error = new ErrorRecord( - new InvalidOperationException(message), - "TeamsMessageDeliveryFailed", - ErrorCategory.ConnectionError, - result.TargetUri) { - ErrorDetails = new ErrorDetails(result.ResponseBody ?? message) + private string GetTypedPayloadName() { + return ParameterSetName switch { + "TypedHeroCard" => "Teams HeroCard", + "TypedThumbnailCard" => "Teams ThumbnailCard", + "TypedListCard" => "Teams ListCard", + _ => "Teams message" }; - - return error; } } diff --git a/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs b/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs new file mode 100644 index 0000000..cea0bc2 --- /dev/null +++ b/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs @@ -0,0 +1,64 @@ +using System.Management.Automation; +using TeamsX; + +namespace TeamsX.PowerShell; + +/// +/// Sends a raw Teams message payload body to an incoming webhook. +/// +[Cmdlet(VerbsCommunications.Send, "TeamsMessageBody", SupportsShouldProcess = true)] +[Alias("TeamsMessageBody")] +[OutputType(typeof(string))] +public sealed class CmdletSendTeamsMessageBody : PSCmdlet { + [Alias("TeamsID", "Url")] + [Parameter(Mandatory = true, Position = 0)] + public Uri Uri { get; set; } = null!; + + [Parameter(Mandatory = false, Position = 1, ValueFromPipeline = true)] + public string? Body { get; set; } + + [Alias("Suppress")] + [Parameter(Mandatory = false)] + public bool Supress { get; set; } = true; + + [Parameter(Mandatory = false)] + public SwitchParameter Wrap { get; set; } + + [Parameter(Mandatory = false)] + public Uri? Proxy { get; set; } + + protected override void ProcessRecord() { + var jsonBody = Wrap.IsPresent + ? WrapMessageBody(Body) + : Body ?? string.Empty; + + WriteVerbose($"Send-TeamsMessageBody - Body {jsonBody}"); + + if (!ShouldProcess(Uri.Host, "Send Teams message body using IncomingWebhook")) { + if (!Supress) { + WriteObject(jsonBody); + } + + return; + } + + var client = TeamsPowerShellDeliverySupport.CreateClient(Proxy); + var target = TeamsMessageTarget.ForIncomingWebhook(Uri); + var result = client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); + + WriteVerbose($"Send-TeamsMessageBody - Execute {result.ResponseBody}"); + TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "Send-TeamsMessageBody"); + + if (!Supress) { + WriteObject(jsonBody); + } + } + + private static string WrapMessageBody(string? body) { + var trimmedBody = string.IsNullOrWhiteSpace(body) + ? "null" + : body!.Trim(); + + return $"{{\"type\":\"message\",\"attachments\":[{trimmedBody}]}}"; + } +} diff --git a/TeamsX.PowerShell/TeamsAdaptiveActionSupport.cs b/TeamsX.PowerShell/TeamsAdaptiveActionSupport.cs new file mode 100644 index 0000000..f1f1a36 --- /dev/null +++ b/TeamsX.PowerShell/TeamsAdaptiveActionSupport.cs @@ -0,0 +1,43 @@ +using TeamsX; + +namespace TeamsX.PowerShell; + +internal static class TeamsAdaptiveActionSupport { + internal static TeamsAdaptiveAction? CreateSelectAction( + string? actionType, + string? actionId, + string? actionUrl, + string? actionTitle, + IEnumerable? targetElements) { + if (!string.IsNullOrWhiteSpace(actionUrl) || string.Equals(actionType, "Action.OpenUrl", StringComparison.OrdinalIgnoreCase)) { + return new TeamsAdaptiveOpenUrlAction { + Id = actionId, + Title = actionTitle ?? string.Empty, + Url = actionUrl ?? string.Empty + }; + } + + if ((targetElements?.Any(static target => !string.IsNullOrWhiteSpace(target)) ?? false) || + string.Equals(actionType, "Action.ToggleVisibility", StringComparison.OrdinalIgnoreCase)) { + var action = new TeamsAdaptiveToggleVisibilityAction { + Id = actionId, + Title = actionTitle ?? string.Empty + }; + + foreach (var targetElement in targetElements ?? Array.Empty()) { + if (!string.IsNullOrWhiteSpace(targetElement)) { + action.TargetElements.Add(targetElement); + } + } + + return action; + } + + return string.Equals(actionType, "Action.Submit", StringComparison.OrdinalIgnoreCase) + ? new TeamsAdaptiveSubmitAction { + Id = actionId, + Title = actionTitle ?? string.Empty + } + : null; + } +} diff --git a/TeamsX.PowerShell/TeamsAdaptiveCardDictionarySupport.cs b/TeamsX.PowerShell/TeamsAdaptiveCardDictionarySupport.cs new file mode 100644 index 0000000..1f239c1 --- /dev/null +++ b/TeamsX.PowerShell/TeamsAdaptiveCardDictionarySupport.cs @@ -0,0 +1,297 @@ +namespace TeamsX.PowerShell; + +using TeamsX; + +internal static class TeamsAdaptiveCardDictionarySupport { + internal static Dictionary ToDictionary(TeamsAdaptiveCard card) { + Dictionary? msTeams = null; + if (card.AllowImageExpand is not null || card.FullWidth || card.Mentions.Count > 0) { + msTeams = new Dictionary(); + if (card.AllowImageExpand is not null) { + msTeams["allowExpand"] = card.AllowImageExpand; + } + + if (card.FullWidth) { + msTeams["width"] = "Full"; + } + + if (card.Mentions.Count > 0) { + msTeams["entities"] = card.Mentions.Select(RenderAdaptiveMention).ToArray(); + } + } + + return new Dictionary { + ["$schema"] = card.Schema, + ["type"] = card.Type, + ["version"] = card.Version, + ["fallbackText"] = EmptyToNull(card.FallbackText), + ["minHeight"] = EmptyToNull(card.MinimumHeight), + ["speak"] = EmptyToNull(card.Speak), + ["lang"] = EmptyToNull(card.Language), + ["verticalContentAlignment"] = EmptyToNull(card.VerticalContentAlignment), + ["backgroundImage"] = card.BackgroundImage, + ["selectAction"] = card.SelectAction is null ? null : RenderAdaptiveAction(card.SelectAction), + ["body"] = card.Body.Select(RenderAdaptiveElement).ToArray(), + ["actions"] = card.Actions.Count == 0 ? null : card.Actions.Select(RenderAdaptiveAction).ToArray(), + ["msteams"] = msTeams + }; + } + + internal static Dictionary? BuildBackgroundImage( + string? backgroundUrl, + string? backgroundFillMode, + string? backgroundHorizontalAlignment, + string? backgroundVerticalAlignment) { + if (string.IsNullOrWhiteSpace(backgroundUrl) && + string.IsNullOrWhiteSpace(backgroundFillMode) && + string.IsNullOrWhiteSpace(backgroundHorizontalAlignment) && + string.IsNullOrWhiteSpace(backgroundVerticalAlignment)) { + return null; + } + + var backgroundImage = new Dictionary(); + if (!string.IsNullOrWhiteSpace(backgroundFillMode)) { + backgroundImage["fillMode"] = backgroundFillMode; + } + + if (!string.IsNullOrWhiteSpace(backgroundHorizontalAlignment)) { + backgroundImage["horizontalAlignment"] = backgroundHorizontalAlignment; + } + + if (!string.IsNullOrWhiteSpace(backgroundVerticalAlignment)) { + backgroundImage["verticalAlignment"] = backgroundVerticalAlignment; + } + + if (!string.IsNullOrWhiteSpace(backgroundUrl)) { + backgroundImage["url"] = backgroundUrl; + } + + return backgroundImage; + } + + private static Dictionary RenderAdaptiveMention(TeamsAdaptiveMention mention) { + return new Dictionary { + ["type"] = mention.Type, + ["text"] = mention.Text, + ["mentioned"] = new Dictionary { + ["id"] = mention.Mentioned.Id, + ["name"] = EmptyToNull(mention.Mentioned.Name) + } + }; + } + + private static Dictionary RenderAdaptiveElement(TeamsAdaptiveCardElement element) { + if (element is TeamsAdaptiveTextBlock textBlock) { + return new Dictionary { + ["type"] = textBlock.Type, + ["text"] = textBlock.Text, + ["id"] = EmptyToNull(textBlock.Id), + ["spacing"] = EmptyToNull(textBlock.Spacing), + ["horizontalAlignment"] = EmptyToNull(textBlock.HorizontalAlignment), + ["wrap"] = textBlock.Wrap, + ["size"] = EmptyToNull(textBlock.Size), + ["weight"] = EmptyToNull(textBlock.Weight), + ["color"] = EmptyToNull(textBlock.Color), + ["height"] = EmptyToNull(textBlock.Height), + ["fontType"] = EmptyToNull(textBlock.FontType), + ["isSubtle"] = textBlock.Subtle, + ["maxLines"] = textBlock.MaximumLines, + ["highlight"] = textBlock.Highlight, + ["italic"] = textBlock.Italic, + ["strikeThrough"] = textBlock.StrikeThrough, + ["separator"] = textBlock.Separator, + ["isVisible"] = textBlock.IsVisible + }; + } + + if (element is TeamsAdaptiveRichTextBlock richTextBlock) { + return new Dictionary { + ["type"] = richTextBlock.Type, + ["id"] = EmptyToNull(richTextBlock.Id), + ["horizontalAlignment"] = EmptyToNull(richTextBlock.HorizontalAlignment), + ["height"] = EmptyToNull(richTextBlock.Height), + ["spacing"] = EmptyToNull(richTextBlock.Spacing), + ["separator"] = richTextBlock.Separator, + ["isVisible"] = richTextBlock.IsVisible, + ["inlines"] = richTextBlock.Inlines.Select(inline => new Dictionary { + ["type"] = "TextRun", + ["text"] = inline.Text, + ["color"] = EmptyToNull(inline.Color), + ["subtle"] = inline.Subtle, + ["size"] = EmptyToNull(inline.Size), + ["weight"] = EmptyToNull(inline.Weight), + ["highlight"] = inline.Highlight, + ["italic"] = inline.Italic, + ["strikethrough"] = inline.StrikeThrough, + ["fontType"] = EmptyToNull(inline.FontType) + }).ToArray() + }; + } + + if (element is TeamsAdaptiveFactSet factSet) { + return new Dictionary { + ["type"] = factSet.Type, + ["height"] = EmptyToNull(factSet.Height), + ["spacing"] = EmptyToNull(factSet.Spacing), + ["separator"] = factSet.Separator, + ["facts"] = factSet.Facts.Select(fact => new Dictionary { + ["title"] = fact.Title, + ["value"] = fact.Value + }).ToArray() + }; + } + + if (element is TeamsAdaptiveImage image) { + return new Dictionary { + ["type"] = image.Type, + ["id"] = EmptyToNull(image.Id), + ["url"] = image.Url, + ["altText"] = EmptyToNull(image.AltText), + ["size"] = EmptyToNull(image.Size), + ["style"] = EmptyToNull(image.Style), + ["horizontalAlignment"] = EmptyToNull(image.HorizontalAlignment), + ["height"] = EmptyToNull(image.Height), + ["width"] = EmptyToNull(image.Width), + ["spacing"] = EmptyToNull(image.Spacing), + ["backgroundColor"] = EmptyToNull(image.BackgroundColor), + ["separator"] = image.Separator, + ["isVisible"] = image.IsVisible, + ["selectAction"] = image.SelectAction is null ? null : RenderAdaptiveAction(image.SelectAction) + }; + } + + if (element is TeamsAdaptiveMedia media) { + return new Dictionary { + ["type"] = media.Type, + ["poster"] = EmptyToNull(media.Poster), + ["altText"] = EmptyToNull(media.AltText), + ["id"] = EmptyToNull(media.Id), + ["horizontalAlignment"] = EmptyToNull(media.HorizontalAlignment), + ["height"] = EmptyToNull(media.Height), + ["spacing"] = EmptyToNull(media.Spacing), + ["separator"] = media.Separator, + ["isVisible"] = media.IsVisible, + ["sources"] = media.Sources.Select(source => new Dictionary { + ["mimeType"] = EmptyToNull(source.MimeType), + ["url"] = EmptyToNull(source.Url) + }).ToArray() + }; + } + + if (element is TeamsAdaptiveImageSet imageSet) { + return new Dictionary { + ["type"] = imageSet.Type, + ["id"] = EmptyToNull(imageSet.Id), + ["imageSize"] = EmptyToNull(imageSet.ImageSize), + ["horizontalAlignment"] = EmptyToNull(imageSet.HorizontalAlignment), + ["height"] = EmptyToNull(imageSet.Height), + ["spacing"] = EmptyToNull(imageSet.Spacing), + ["separator"] = imageSet.Separator, + ["isVisible"] = imageSet.IsVisible, + ["images"] = imageSet.Images.Select(image => (object?)RenderAdaptiveElement(image)).ToArray() + }; + } + + if (element is TeamsAdaptiveContainer container) { + return new Dictionary { + ["type"] = container.Type, + ["id"] = EmptyToNull(container.Id), + ["style"] = EmptyToNull(container.Style), + ["verticalContentAlignment"] = EmptyToNull(container.VerticalContentAlignment), + ["horizontalAlignment"] = EmptyToNull(container.HorizontalAlignment), + ["height"] = EmptyToNull(container.Height), + ["spacing"] = EmptyToNull(container.Spacing), + ["bleed"] = container.Bleed, + ["minHeight"] = EmptyToNull(container.MinimumHeight), + ["separator"] = container.Separator, + ["isVisible"] = container.IsVisible, + ["backgroundImage"] = container.BackgroundImage, + ["selectAction"] = container.SelectAction is null ? null : RenderAdaptiveAction(container.SelectAction), + ["items"] = container.Items.Select(RenderAdaptiveElement).ToArray() + }; + } + + if (element is TeamsAdaptiveColumn column) { + return new Dictionary { + ["type"] = column.Type, + ["width"] = EmptyToNull(column.Width), + ["height"] = EmptyToNull(column.Height), + ["minHeight"] = EmptyToNull(column.MinimumHeight), + ["horizontalAlignment"] = EmptyToNull(column.HorizontalAlignment), + ["verticalContentAlignment"] = EmptyToNull(column.VerticalContentAlignment), + ["spacing"] = EmptyToNull(column.Spacing), + ["style"] = EmptyToNull(column.Style), + ["isVisible"] = column.IsVisible, + ["separator"] = column.Separator, + ["selectAction"] = column.SelectAction is null ? null : RenderAdaptiveAction(column.SelectAction), + ["items"] = column.Items.Select(RenderAdaptiveElement).ToArray() + }; + } + + if (element is TeamsAdaptiveColumnSet columnSet) { + return new Dictionary { + ["type"] = columnSet.Type, + ["style"] = EmptyToNull(columnSet.Style), + ["minHeight"] = EmptyToNull(columnSet.MinimumHeight), + ["bleed"] = columnSet.Bleed, + ["horizontalAlignment"] = EmptyToNull(columnSet.HorizontalAlignment), + ["height"] = EmptyToNull(columnSet.Height), + ["spacing"] = EmptyToNull(columnSet.Spacing), + ["separator"] = columnSet.Separator, + ["columns"] = columnSet.Columns.Select(column => (object?)RenderAdaptiveElement(column)).ToArray() + }; + } + + if (element is TeamsAdaptiveActionSet actionSet) { + return new Dictionary { + ["type"] = actionSet.Type, + ["actions"] = actionSet.Actions.Select(RenderAdaptiveAction).ToArray() + }; + } + + throw new NotSupportedException($"Adaptive element '{element.GetType().Name}' is not supported by the PowerShell show-card renderer yet."); + } + + private static Dictionary RenderAdaptiveAction(TeamsAdaptiveAction action) { + if (action is TeamsAdaptiveOpenUrlAction openUrlAction) { + return new Dictionary { + ["type"] = openUrlAction.Type, + ["id"] = EmptyToNull(openUrlAction.Id), + ["title"] = EmptyToNull(openUrlAction.Title), + ["url"] = openUrlAction.Url + }; + } + + if (action is TeamsAdaptiveToggleVisibilityAction toggleVisibilityAction) { + return new Dictionary { + ["type"] = toggleVisibilityAction.Type, + ["id"] = EmptyToNull(toggleVisibilityAction.Id), + ["title"] = EmptyToNull(toggleVisibilityAction.Title), + ["targetElements"] = toggleVisibilityAction.TargetElements.ToArray() + }; + } + + if (action is TeamsAdaptiveSubmitAction submitAction) { + return new Dictionary { + ["type"] = submitAction.Type, + ["id"] = EmptyToNull(submitAction.Id), + ["title"] = EmptyToNull(submitAction.Title) + }; + } + + if (action is TeamsAdaptiveShowCardAction showCardAction) { + return new Dictionary { + ["type"] = showCardAction.Type, + ["id"] = EmptyToNull(showCardAction.Id), + ["title"] = EmptyToNull(showCardAction.Title), + ["card"] = TeamsLegacyAdaptiveNormalizer.Normalize(showCardAction.Card) + }; + } + + throw new NotSupportedException($"Adaptive action '{action.GetType().Name}' is not supported by the PowerShell show-card renderer yet."); + } + + private static string? EmptyToNull(string? value) { + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs b/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs new file mode 100644 index 0000000..a6a9f29 --- /dev/null +++ b/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs @@ -0,0 +1,63 @@ +using System.Management.Automation; +using System.Net; +using System.Net.Http; +using TeamsX; + +namespace TeamsX.PowerShell; + +internal static class TeamsPowerShellDeliverySupport { + public static TeamsClient CreateClient(Uri? proxy) { + if (proxy is null) { + return TeamsClient.Default; + } + + var handler = new HttpClientHandler { + Proxy = new WebProxy(proxy), + UseProxy = true + }; + var httpClient = new HttpClient(handler, disposeHandler: true); + var sender = new WebhookTeamsMessageSender(httpClient, disposeHttpClient: true); + + return new TeamsClient(new ITeamsMessageSender[] { sender }); + } + + public static void WriteDeliveryIssue(PSCmdlet cmdlet, TeamsDeliveryResult result, string commandName) { + if (!result.IsSuccessStatusCode) { + cmdlet.WriteError(CreateDeliveryFailureError(result, commandName)); + return; + } + + if (LooksLikeFailureMessage(result.ResponseBody)) { + var message = $"{commandName} - Couldn't send message. Execute message: {result.ResponseBody}"; + cmdlet.WriteError(new ErrorRecord( + new InvalidOperationException(message), + "TeamsMessageDeliveryFailed", + ErrorCategory.ConnectionError, + result.TargetUri)); + } + } + + public static ErrorRecord CreateDeliveryFailureError(TeamsDeliveryResult result, string commandName) { + var statusCode = result.StatusCode?.ToString() ?? "unknown"; + var message = $"{commandName} - Couldn't send message. HTTP status: {statusCode}."; + var error = new ErrorRecord( + new InvalidOperationException(message), + "TeamsMessageDeliveryFailed", + ErrorCategory.ConnectionError, + result.TargetUri) { + ErrorDetails = new ErrorDetails(result.ResponseBody ?? message) + }; + + return error; + } + + public static bool LooksLikeFailureMessage(string? responseBody) { + if (string.IsNullOrWhiteSpace(responseBody)) { + return false; + } + + var body = responseBody!; + return body.IndexOf("failed", StringComparison.OrdinalIgnoreCase) >= 0 || + body.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/TeamsX.PowerShell/TeamsPowerShellGraphTokenSupport.cs b/TeamsX.PowerShell/TeamsPowerShellGraphTokenSupport.cs new file mode 100644 index 0000000..b8341b8 --- /dev/null +++ b/TeamsX.PowerShell/TeamsPowerShellGraphTokenSupport.cs @@ -0,0 +1,35 @@ +using System.Runtime.InteropServices; +using System.Security; + +namespace TeamsX.PowerShell; + +internal static class TeamsPowerShellGraphTokenSupport { + public static string ReadEnvironmentVariable(string variableName) { + if (string.IsNullOrWhiteSpace(variableName)) { + throw new InvalidOperationException("Environment variable name cannot be null or whitespace."); + } + + var value = Environment.GetEnvironmentVariable(variableName); + if (string.IsNullOrWhiteSpace(value)) { + throw new InvalidOperationException($"Environment variable '{variableName}' does not contain a usable Graph access token."); + } + + return value; + } + + public static string ConvertToUnsecureString(SecureString secureString) { + if (secureString is null) { + throw new InvalidOperationException("Secure access token cannot be null."); + } + + var pointer = IntPtr.Zero; + try { + pointer = Marshal.SecureStringToGlobalAllocUnicode(secureString); + return Marshal.PtrToStringUni(pointer) ?? string.Empty; + } finally { + if (pointer != IntPtr.Zero) { + Marshal.ZeroFreeGlobalAllocUnicode(pointer); + } + } + } +} diff --git a/TeamsX.PowerShell/TeamsPowerShellImageSupport.cs b/TeamsX.PowerShell/TeamsPowerShellImageSupport.cs new file mode 100644 index 0000000..0673cf1 --- /dev/null +++ b/TeamsX.PowerShell/TeamsPowerShellImageSupport.cs @@ -0,0 +1,38 @@ +using System.IO; +using System.Linq; +using TeamsX; + +namespace TeamsX.PowerShell; + +internal static class TeamsPowerShellImageSupport { + public static void ValidateImageFile(FileInfo path, string parameterName, string missingMessage, string extensionMessage) { + if (!path.Exists) { + throw new FileNotFoundException(missingMessage, path.FullName); + } + + var extension = path.Extension; + if (!string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)) { + throw new ArgumentException(extensionMessage, parameterName); + } + } + + public static string ResolveImageFile(FileInfo path) { + return TeamsImageDataUtility.FromFile(path.FullName); + } + + public static string ResolveBuiltInImage(string imageName) { + var assemblyDirectory = Path.GetDirectoryName(typeof(TeamsPowerShellImageSupport).Assembly.Location) ?? string.Empty; + var candidates = new[] { + Path.GetFullPath(Path.Combine(assemblyDirectory, "..", "..", "Images", $"{imageName}.jpg")), + Path.GetFullPath(Path.Combine(assemblyDirectory, "..", "..", "..", "..", "Module", "PSTeams", "Images", $"{imageName}.jpg")) + }; + + var imagePath = candidates.FirstOrDefault(File.Exists); + if (imagePath is null) { + return string.Empty; + } + + return TeamsImageDataUtility.FromFile(imagePath); + } +} diff --git a/TeamsX.PowerShell/TeamsX.PowerShell.csproj b/TeamsX.PowerShell/TeamsX.PowerShell.csproj index 7cc90d2..9f47ab5 100644 --- a/TeamsX.PowerShell/TeamsX.PowerShell.csproj +++ b/TeamsX.PowerShell/TeamsX.PowerShell.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/TeamsX.Tests/GraphMessageRendererTests.cs b/TeamsX.Tests/GraphMessageRendererTests.cs new file mode 100644 index 0000000..d37a661 --- /dev/null +++ b/TeamsX.Tests/GraphMessageRendererTests.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using TeamsX; + +namespace TeamsX.Tests; + +public class GraphMessageRendererTests { + [Fact] + public void RenderHtmlMessageIncludesTitleTextSectionsAndLinks() { + var request = new TeamsMessageRequest { + Title = "Build failed", + Text = "Pipeline 42 stopped." + }; + request.Sections.Add(new TeamsMessageSection { + ActivityTitle = "Release pipeline", + ActivitySubtitle = "Run 42", + ActivityText = "Deployment stopped after test failures." + }); + request.Sections[0].Facts.Add(new TeamsMessageFact { Name = "Status", Value = "Failed" }); + request.Sections[0].Buttons.Add(new TeamsMessageButton { + Name = "Open build", + Link = "https://example.test/build/42", + ButtonType = TeamsMessageButtonType.OpenUri + }); + + var json = GraphMessageRenderer.Render(request, TeamsDeliveryMethod.GraphChannelMessage); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.Equal("Build failed", root.GetProperty("subject").GetString()); + var body = root.GetProperty("body"); + Assert.Equal("html", body.GetProperty("contentType").GetString()); + + var html = body.GetProperty("content").GetString(); + Assert.Contains("Build failed", html); + Assert.Contains("Pipeline 42 stopped.", html); + Assert.Contains("Release pipeline", html); + Assert.Contains("Status", html); + Assert.Contains("https://example.test/build/42", html); + } + + [Fact] + public void RenderAdaptiveCardMessageCreatesGraphAttachmentPayload() { + var request = new TeamsMessageRequest { + Summary = "Build summary", + Text = "Pipeline 42 stopped.", + AdaptiveCard = new TeamsAdaptiveCard() + }; + request.AdaptiveCard.Body.Add(new TeamsAdaptiveTextBlock { + Text = "Build failed" + }); + request.AdaptiveCard.Actions.Add(new TeamsAdaptiveOpenUrlAction { + Title = "Open build", + Url = "https://example.test/build/42" + }); + + var json = GraphMessageRenderer.Render(request, TeamsDeliveryMethod.GraphChatMessage); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (root.TryGetProperty("subject", out var subject)) { + Assert.Equal(JsonValueKind.Null, subject.ValueKind); + } + var body = root.GetProperty("body"); + var content = body.GetProperty("content").GetString(); + Assert.Contains(" GraphMessageRenderer.Render(request, TeamsDeliveryMethod.GraphChatMessage); + + Assert.Throws(action); + } +} diff --git a/TeamsX.Tests/GraphTeamsMessageSenderTests.cs b/TeamsX.Tests/GraphTeamsMessageSenderTests.cs new file mode 100644 index 0000000..5674cc5 --- /dev/null +++ b/TeamsX.Tests/GraphTeamsMessageSenderTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using TeamsX; + +namespace TeamsX.Tests; + +public class GraphTeamsMessageSenderTests { + [Fact] + public async Task SendAsyncPostsGraphJsonWithBearerToken() { + var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler); + using var sender = new GraphTeamsMessageSender(httpClient); + + var target = TeamsMessageTarget.ForGraphChatMessage( + "19:testchat@thread.v2", + "token-value", + "Ops Chat", + new Uri("https://graph.example.test/")); + var request = new TeamsMessageRequest { + Text = "Build failed" + }; + + var result = await sender.SendAsync(request, target); + + Assert.True(result.IsSuccessStatusCode); + Assert.Equal("Bearer", handler.AuthorizationScheme); + Assert.Equal("token-value", handler.AuthorizationToken); + Assert.Equal("https://graph.example.test/v1.0/chats/19%3Atestchat%40thread.v2/messages", handler.RequestUri); + Assert.Contains("\"contentType\":\"html\"", handler.Body); + Assert.Contains("Build failed", handler.Body); + } + + [Fact] + public async Task SendJsonAsyncRequiresAccessToken() { + var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler); + using var sender = new GraphTeamsMessageSender(httpClient); + + var target = new TeamsMessageTarget { + DeliveryMethod = TeamsDeliveryMethod.GraphChatMessage, + TargetUri = new Uri("https://graph.example.test/v1.0/chats/abc/messages") + }; + + await Assert.ThrowsAsync(() => sender.SendJsonAsync("{}", target)); + } + + [Fact] + public async Task SendJsonAsyncUsesDynamicAccessTokenProviderWhenPresent() { + var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler); + using var sender = new GraphTeamsMessageSender(httpClient); + + var target = TeamsMessageTarget.ForGraphChatMessage( + "19:testchat@thread.v2", + _ => Task.FromResult("dynamic-token"), + "Ops Chat", + new Uri("https://graph.example.test/")); + + var result = await sender.SendJsonAsync("{\"body\":{\"content\":\"hello\"}}", target); + + Assert.True(result.IsSuccessStatusCode); + Assert.Equal("dynamic-token", handler.AuthorizationToken); + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler { + public string? AuthorizationScheme { get; private set; } + public string? AuthorizationToken { get; private set; } + public string? RequestUri { get; private set; } + public string Body { get; private set; } = string.Empty; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + AuthorizationScheme = request.Headers.Authorization?.Scheme; + AuthorizationToken = request.Headers.Authorization?.Parameter; + RequestUri = request.RequestUri?.ToString(); + Body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.Created) { + Content = new StringContent("{\"id\":\"message-123\"}", Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/TeamsX.Tests/TeamsAdaptiveCardTests.cs b/TeamsX.Tests/TeamsAdaptiveCardTests.cs index 324613a..07eb394 100644 --- a/TeamsX.Tests/TeamsAdaptiveCardTests.cs +++ b/TeamsX.Tests/TeamsAdaptiveCardTests.cs @@ -54,4 +54,25 @@ public void CardCanHoldColumnsAndActions() { Assert.Single(card.Actions); Assert.IsType(card.Body[0]); } + + [Fact] + public void CardCanHoldSubmitAndShowCardActions() { + var card = new TeamsAdaptiveCard(); + card.Actions.Add(new TeamsAdaptiveSubmitAction { Title = "Approve" }); + card.Actions.Add(new TeamsAdaptiveShowCardAction { + Title = "Details", + Card = new Dictionary { + ["$schema"] = "http://adaptivecards.io/schemas/adaptive-card.json", + ["type"] = "AdaptiveCard", + ["version"] = "1.2", + ["body"] = new object[] { + new TeamsAdaptiveTextBlock { Text = "Nested details" } + } + } + }); + + Assert.Equal(2, card.Actions.Count); + Assert.IsType(card.Actions[0]); + Assert.IsType(card.Actions[1]); + } } diff --git a/TeamsX.Tests/TeamsClientTests.cs b/TeamsX.Tests/TeamsClientTests.cs index a5142d9..78d5bc9 100644 --- a/TeamsX.Tests/TeamsClientTests.cs +++ b/TeamsX.Tests/TeamsClientTests.cs @@ -19,6 +19,41 @@ public async Task SendJsonAsyncUsesLaterRawCapableSender() { Assert.Equal("{\"text\":\"hello\"}", result.ResponseBody); } + [Fact] + public async Task SendAsyncHeroCardUsesRawCapableSender() { + var target = TeamsMessageTarget.ForIncomingWebhook(new Uri("https://example.test/webhook")); + var rawSender = new RawWebhookSender(); + var client = new TeamsClient(new ITeamsMessageSender[] { + new TypedOnlyWebhookSender(), + rawSender + }); + + var result = await client.SendAsync(new TeamsHeroCard { + Title = "Hero" + }, target); + + Assert.True(rawSender.WasCalled); + Assert.True(result.IsSuccessStatusCode); + Assert.Contains("\"type\":\"message\"", result.ResponseBody); + Assert.Contains("\"contentType\":\"application/vnd.microsoft.card.hero\"", result.ResponseBody); + } + + [Fact] + public async Task SendAsyncHeroCardRejectsUnsupportedDeliveryMethods() { + var target = TeamsMessageTarget.ForGraphChatMessage("19:testchat@thread.v2", "token-1"); + var client = new TeamsClient(new ITeamsMessageSender[] { + new TypedOnlyWebhookSender(), + new RawWebhookSender() + }); + + var action = async () => await client.SendAsync(new TeamsHeroCard { + Title = "Hero" + }, target); + + var exception = await Assert.ThrowsAsync(action); + Assert.Contains("incoming and workflow webhooks", exception.Message); + } + private sealed class TypedOnlyWebhookSender : ITeamsMessageSender { public bool CanSend(TeamsDeliveryMethod deliveryMethod) { return deliveryMethod is TeamsDeliveryMethod.IncomingWebhook; diff --git a/TeamsX.Tests/TeamsMessageTargetTests.cs b/TeamsX.Tests/TeamsMessageTargetTests.cs index 7e448c8..f7e4a4c 100644 --- a/TeamsX.Tests/TeamsMessageTargetTests.cs +++ b/TeamsX.Tests/TeamsMessageTargetTests.cs @@ -22,4 +22,46 @@ public void ForIncomingWebhookRequiresAbsoluteUri() { Assert.Throws(action); } + + [Fact] + public void ForGraphChannelMessageCreatesGraphTarget() { + var target = TeamsMessageTarget.ForGraphChannelMessage( + "team-1", + "channel-1", + "token-1", + "alerts", + new Uri("https://graph.example.test/")); + + Assert.Equal(TeamsDeliveryMethod.GraphChannelMessage, target.DeliveryMethod); + Assert.Equal("https://graph.example.test/v1.0/teams/team-1/channels/channel-1/messages", target.TargetUri.ToString()); + Assert.Equal("token-1", target.AccessToken); + Assert.Equal("alerts", target.DisplayName); + } + + [Fact] + public void ForGraphChatMessageCreatesGraphTarget() { + var target = TeamsMessageTarget.ForGraphChatMessage( + "19:testchat@thread.v2", + "token-1", + "ops-chat", + new Uri("https://graph.example.test/")); + + Assert.Equal(TeamsDeliveryMethod.GraphChatMessage, target.DeliveryMethod); + Assert.Equal("https://graph.example.test/v1.0/chats/19%3Atestchat%40thread.v2/messages", target.TargetUri.ToString()); + Assert.Equal("token-1", target.AccessToken); + Assert.Equal("ops-chat", target.DisplayName); + } + + [Fact] + public void ForGraphChatMessageSupportsDynamicAccessTokenProvider() { + var target = TeamsMessageTarget.ForGraphChatMessage( + "19:testchat@thread.v2", + _ => Task.FromResult("token-2"), + "ops-chat", + new Uri("https://graph.example.test/")); + + Assert.Equal(TeamsDeliveryMethod.GraphChatMessage, target.DeliveryMethod); + Assert.True(target.HasDynamicAccessToken); + Assert.Null(target.AccessToken); + } } diff --git a/TeamsX.Tests/WebhookMessageRendererTests.cs b/TeamsX.Tests/WebhookMessageRendererTests.cs index 7f912cb..8963e99 100644 --- a/TeamsX.Tests/WebhookMessageRendererTests.cs +++ b/TeamsX.Tests/WebhookMessageRendererTests.cs @@ -17,6 +17,69 @@ public void RenderUsesSummaryFallbacks() { Assert.Contains("\"text\":\"Pipeline 42\"", json); } + [Fact] + public void RenderSupportsConnectorCardSectionsFactsAndButtons() { + var request = new TeamsMessageRequest { + Title = "Build failed", + Text = "Pipeline 42", + ThemeColor = "#1E90FF", + UseConnectorCardFormat = true + }; + request.Sections.Add(new TeamsMessageSection { + Title = "Build summary", + ActivityText = "Pipeline failed", + Facts = { + new TeamsMessageFact { Name = "Status", Value = "Failed" } + }, + Buttons = { + new TeamsMessageButton { + Name = "Open build", + Link = "https://example.test/build/42", + ButtonType = TeamsMessageButtonType.OpenUri + } + } + }); + + var json = WebhookMessageRenderer.Render(request); + + Assert.Contains("\"themeColor\":\"#1E90FF\"", json); + Assert.Contains("\"sections\":[", json); + Assert.Contains("\"title\":\"Build summary\"", json); + Assert.Contains("\"name\":\"Status\"", json); + Assert.Contains("\"@type\":\"OpenURI\"", json); + Assert.Contains("\"uri\":\"https://example.test/build/42\"", json); + } + + [Fact] + public void RenderSupportsConnectorCardSectionImagesAndHeroImageMarkdown() { + var request = new TeamsMessageRequest { + Title = "Build failed", + UseConnectorCardFormat = true + }; + request.Sections.Add(new TeamsMessageSection { + ActivityTitle = "Build title", + ActivitySubtitle = "Build subtitle", + ActivityText = "Build text", + ActivityImage = "https://example.test/activity.png", + Text = "Original text", + Images = { + "https://example.test/image.png" + }, + HeroImages = { + "![Hero](https://example.test/hero.png)" + } + }); + + var json = WebhookMessageRenderer.Render(request); + + Assert.Contains("\"activityTitle\":\"Build title\"", json); + Assert.Contains("\"activitySubtitle\":\"Build subtitle\"", json); + Assert.Contains("\"activityText\":\"Build text\"", json); + Assert.Contains("\"activityImage\":\"https://example.test/activity.png\"", json); + Assert.Contains("\"images\":[{\"image\":\"https://example.test/image.png\"}]", json); + Assert.Contains("![Hero](https://example.test/hero.png) Original text", json); + } + [Fact] public void RenderWrapsAdaptiveCardAsAttachmentMessage() { var request = new TeamsMessageRequest { @@ -46,6 +109,24 @@ public void RenderSupportsFactSetImageAndContainer() { AdaptiveCard = new TeamsAdaptiveCard { Body = { new TeamsAdaptiveContainer { + Id = "panel", + Style = "Emphasis", + MinimumHeight = "120px", + Bleed = true, + VerticalContentAlignment = "center", + HorizontalAlignment = "Center", + Height = "Stretch", + Spacing = "Medium", + Separator = true, + IsVisible = false, + BackgroundImage = new Dictionary { + ["fillMode"] = "Cover", + ["url"] = "https://example.test/background.png" + }, + SelectAction = new TeamsAdaptiveOpenUrlAction { + Title = "Open panel", + Url = "https://example.test/panel" + }, Items = { new TeamsAdaptiveImage { Url = "https://example.test/build.png", @@ -66,6 +147,15 @@ public void RenderSupportsFactSetImageAndContainer() { var json = WebhookMessageRenderer.Render(request); Assert.Contains("\"type\":\"Container\"", json); + Assert.Contains("\"id\":\"panel\"", json); + Assert.Contains("\"style\":\"Emphasis\"", json); + Assert.Contains("\"minHeight\":\"120px\"", json); + Assert.Contains("\"bleed\":true", json); + Assert.Contains("\"verticalContentAlignment\":\"center\"", json); + Assert.Contains("\"backgroundImage\":{\"fillMode\":\"Cover\",\"url\":\"https://example.test/background.png\"}", json); + Assert.Contains("\"selectAction\":{\"type\":\"Action.OpenUrl\"", json); + Assert.Contains("\"title\":\"Open panel\"", json); + Assert.Contains("\"url\":\"https://example.test/panel\"", json); Assert.Contains("\"type\":\"Image\"", json); Assert.Contains("\"url\":\"https://example.test/build.png\"", json); Assert.Contains("\"type\":\"FactSet\"", json); @@ -80,9 +170,31 @@ public void RenderSupportsColumnSetAndOpenUrlAction() { AdaptiveCard = new TeamsAdaptiveCard { Body = { new TeamsAdaptiveColumnSet { + Style = "Good", + MinimumHeight = "80px", + Bleed = true, + HorizontalAlignment = "Center", + Height = "Stretch", + Spacing = "Medium", + Separator = true, Columns = { new TeamsAdaptiveColumn { Width = "stretch", + Height = "Stretch", + MinimumHeight = "60px", + HorizontalAlignment = "Right", + VerticalContentAlignment = "Bottom", + Spacing = "Small", + Style = "Attention", + IsVisible = false, + Separator = true, + SelectAction = new TeamsAdaptiveToggleVisibilityAction { + Id = "toggle-column", + Title = "Toggle column", + TargetElements = { + "detailsBlock" + } + }, Items = { new TeamsAdaptiveTextBlock { Text = "Pipeline failed" } } @@ -116,8 +228,13 @@ public void RenderSupportsColumnSetAndOpenUrlAction() { var json = WebhookMessageRenderer.Render(request); Assert.Contains("\"type\":\"ColumnSet\"", json); + Assert.Contains("\"style\":\"Good\"", json); + Assert.Contains("\"minHeight\":\"80px\"", json); + Assert.Contains("\"bleed\":true", json); Assert.Contains("\"type\":\"Column\"", json); Assert.Contains("\"width\":\"stretch\"", json); + Assert.Contains("\"verticalContentAlignment\":\"Bottom\"", json); + Assert.Contains("\"selectAction\":{\"type\":\"Action.ToggleVisibility\",\"id\":\"toggle-column\",\"title\":\"Toggle column\",\"targetElements\":[\"detailsBlock\"]}", json); Assert.Contains("\"type\":\"ActionSet\"", json); Assert.Contains("\"type\":\"Action.OpenUrl\"", json); Assert.Contains("\"url\":\"https://example.test/build/42\"", json); @@ -218,4 +335,29 @@ public void RenderSupportsImageSetAndToggleVisibilityAction() { Assert.Contains("\"type\":\"Action.ToggleVisibility\"", json); Assert.Contains("\"targetElements\":[\"detailsBlock\",\"detailsFactSet\"]", json); } + + [Fact] + public void WrapperCardRendererSupportsTypedWrapperModels() { + var heroJson = TeamsWrapperCardRenderer.Render(new TeamsHeroCard { + Title = "Hero" + }); + + var thumbnailJson = TeamsWrapperCardRenderer.Render(new TeamsThumbnailCard { + Title = "Thumb" + }); + + var listCard = new TeamsListCard { + Title = "List" + }; + listCard.Items.Add(new TeamsListCardItem { + Kind = TeamsListCardItemKind.ResultItem, + Title = "Item" + }); + + var listJson = TeamsWrapperCardRenderer.Render(listCard); + + Assert.Contains("\"contentType\":\"application/vnd.microsoft.card.hero\"", heroJson); + Assert.Contains("\"contentType\":\"application/vnd.microsoft.card.thumbnail\"", thumbnailJson); + Assert.Contains("\"contentType\":\"application/vnd.microsoft.teams.card.list\"", listJson); + } } diff --git a/TeamsX/GraphMessageRenderer.cs b/TeamsX/GraphMessageRenderer.cs new file mode 100644 index 0000000..0240e22 --- /dev/null +++ b/TeamsX/GraphMessageRenderer.cs @@ -0,0 +1,214 @@ +using System.Net; + +namespace TeamsX; + +internal static class GraphMessageRenderer { + public static string Render(TeamsMessageRequest request, TeamsDeliveryMethod deliveryMethod) { + if (request is null) { + throw new ArgumentNullException(nameof(request)); + } + + if (request.AdaptiveCard is not null) { + return RenderAdaptiveCardMessage(request, deliveryMethod); + } + + return RenderHtmlMessage(request, deliveryMethod); + } + + private static string RenderAdaptiveCardMessage(TeamsMessageRequest request, TeamsDeliveryMethod deliveryMethod) { + ValidateAdaptiveCardForGraph(request.AdaptiveCard!); + + var attachmentId = Guid.NewGuid().ToString("D"); + var bodyFragments = BuildBodyFragments(request); + bodyFragments.Add($""); + + var payload = new Dictionary { + ["subject"] = deliveryMethod is TeamsDeliveryMethod.GraphChannelMessage + ? EmptyToNull(request.Title) + : null, + ["body"] = new Dictionary { + ["contentType"] = "html", + ["content"] = string.Join(string.Empty, bodyFragments) + }, + ["attachments"] = new[] { + new Dictionary { + ["id"] = attachmentId, + ["contentType"] = "application/vnd.microsoft.card.adaptive", + ["content"] = TeamsJsonSerializer.Serialize(WebhookMessageRenderer.RenderAdaptiveCard(request.AdaptiveCard!)), + ["name"] = EmptyToNull(request.EffectiveSummary) + } + } + }; + + return TeamsJsonSerializer.Serialize(payload); + } + + private static string RenderHtmlMessage(TeamsMessageRequest request, TeamsDeliveryMethod deliveryMethod) { + var bodyFragments = BuildBodyFragments(request); + if (bodyFragments.Count == 0) { + bodyFragments.Add(WrapParagraph(request.EffectiveSummary)); + } + + var payload = new Dictionary { + ["subject"] = deliveryMethod is TeamsDeliveryMethod.GraphChannelMessage + ? EmptyToNull(request.Title) + : null, + ["body"] = new Dictionary { + ["contentType"] = "html", + ["content"] = string.Join(string.Empty, bodyFragments) + } + }; + + return TeamsJsonSerializer.Serialize(payload); + } + + private static List BuildBodyFragments(TeamsMessageRequest request) { + var fragments = new List(); + + if (!string.IsNullOrWhiteSpace(request.Title)) { + fragments.Add($"
{Encode(request.Title)}
"); + } + + if (!string.IsNullOrWhiteSpace(request.Text)) { + fragments.Add(WrapParagraph(request.Text)); + } + + foreach (var section in request.Sections) { + fragments.Add(RenderSection(section)); + } + + if (fragments.Count == 0 && !string.IsNullOrWhiteSpace(request.Summary)) { + fragments.Add(WrapParagraph(request.Summary)); + } + + return fragments; + } + + private static string RenderSection(TeamsMessageSection section) { + var fragments = new List(); + + if (!string.IsNullOrWhiteSpace(section.Title)) { + fragments.Add($"
{Encode(section.Title)}
"); + } + + if (!string.IsNullOrWhiteSpace(section.ActivityTitle)) { + fragments.Add($"
{Encode(section.ActivityTitle)}
"); + } + + if (!string.IsNullOrWhiteSpace(section.ActivitySubtitle)) { + fragments.Add($"
{Encode(section.ActivitySubtitle)}
"); + } + + if (!string.IsNullOrWhiteSpace(section.ActivityText)) { + fragments.Add(WrapParagraph(section.ActivityText)); + } + + if (!string.IsNullOrWhiteSpace(section.Text)) { + fragments.Add(WrapParagraph(section.Text)); + } + + if (section.Facts.Count > 0) { + var facts = string.Join(string.Empty, section.Facts.Select(fact => + $"
  • {Encode(fact.Name)}: {Encode(fact.Value)}
  • ")); + fragments.Add($"
      {facts}
    "); + } + + if (section.Buttons.Count > 0) { + var buttons = string.Join(" ", section.Buttons + .Where(button => !string.IsNullOrWhiteSpace(button.Link)) + .Select(button => $"{Encode(button.Name)}")); + if (!string.IsNullOrWhiteSpace(buttons)) { + fragments.Add($"
    {buttons}
    "); + } + } + + return string.Join(string.Empty, fragments); + } + + private static string WrapParagraph(string? value) { + return $"

    {EncodeMultiline(value)}

    "; + } + + private static string EncodeMultiline(string? value) { + return Encode(value).Replace("\r\n", "
    ") + .Replace("\n", "
    "); + } + + private static string Encode(string? value) { + return WebUtility.HtmlEncode(value ?? string.Empty); + } + + private static string EncodeAttribute(string? value) { + return WebUtility.HtmlEncode(value ?? string.Empty); + } + + private static string? EmptyToNull(string? value) { + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static void ValidateAdaptiveCardForGraph(TeamsAdaptiveCard card) { + foreach (var action in card.Actions) { + ValidateAction(action); + } + + if (card.SelectAction is not null) { + ValidateAction(card.SelectAction); + } + + foreach (var element in card.Body) { + ValidateElement(element); + } + } + + private static void ValidateElement(TeamsAdaptiveCardElement element) { + switch (element) { + case TeamsAdaptiveContainer container: + if (container.SelectAction is not null) { + ValidateAction(container.SelectAction); + } + foreach (var item in container.Items) { + ValidateElement(item); + } + return; + case TeamsAdaptiveColumn column: + if (column.SelectAction is not null) { + ValidateAction(column.SelectAction); + } + foreach (var item in column.Items) { + ValidateElement(item); + } + return; + case TeamsAdaptiveColumnSet columnSet: + foreach (var columnItem in columnSet.Columns) { + ValidateElement(columnItem); + } + return; + case TeamsAdaptiveImage image: + if (image.SelectAction is not null) { + ValidateAction(image.SelectAction); + } + return; + case TeamsAdaptiveActionSet actionSet: + foreach (var action in actionSet.Actions) { + ValidateAction(action); + } + return; + case TeamsAdaptiveImageSet: + case TeamsAdaptiveMedia: + case TeamsAdaptiveFactSet: + case TeamsAdaptiveTextBlock: + case TeamsAdaptiveRichTextBlock: + return; + default: + return; + } + } + + private static void ValidateAction(TeamsAdaptiveAction action) { + if (action is TeamsAdaptiveOpenUrlAction) { + return; + } + + throw new NotSupportedException($"Adaptive action '{action.Type}' is not supported for Graph chat messages. Only Action.OpenUrl is supported."); + } +} diff --git a/TeamsX/GraphTeamsMessageSender.cs b/TeamsX/GraphTeamsMessageSender.cs new file mode 100644 index 0000000..1e69183 --- /dev/null +++ b/TeamsX/GraphTeamsMessageSender.cs @@ -0,0 +1,99 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace TeamsX; + +public sealed class GraphTeamsMessageSender : ITeamsMessageSender, ITeamsRawMessageSender, IDisposable { + internal static GraphTeamsMessageSender Shared { get; } = new(new HttpClient(), disposeHttpClient: false); + + private readonly HttpClient _httpClient; + private readonly bool _disposeHttpClient; + + public GraphTeamsMessageSender() + : this(new HttpClient(), disposeHttpClient: true) { + } + + public GraphTeamsMessageSender(HttpClient httpClient, bool disposeHttpClient = false) { + if (httpClient is null) { + throw new ArgumentNullException(nameof(httpClient)); + } + + _httpClient = httpClient; + _disposeHttpClient = disposeHttpClient; + } + + public bool CanSend(TeamsDeliveryMethod deliveryMethod) { + return deliveryMethod is TeamsDeliveryMethod.GraphChannelMessage or TeamsDeliveryMethod.GraphChatMessage; + } + + public async Task SendAsync( + TeamsMessageRequest message, + TeamsMessageTarget target, + CancellationToken cancellationToken = default) { + if (message is null) { + throw new ArgumentNullException(nameof(message)); + } + if (target is null) { + throw new ArgumentNullException(nameof(target)); + } + if (!CanSend(target.DeliveryMethod)) { + throw new InvalidOperationException($"Graph sender cannot send using '{target.DeliveryMethod}'."); + } + + var jsonBody = GraphMessageRenderer.Render(message, target.DeliveryMethod); + return await SendJsonAsync(jsonBody, target, cancellationToken).ConfigureAwait(false); + } + + public async Task SendJsonAsync( + string jsonBody, + TeamsMessageTarget target, + CancellationToken cancellationToken = default) { + if (jsonBody is null) { + throw new ArgumentNullException(nameof(jsonBody)); + } + if (target is null) { + throw new ArgumentNullException(nameof(target)); + } + if (!CanSend(target.DeliveryMethod)) { + throw new InvalidOperationException($"Graph sender cannot send using '{target.DeliveryMethod}'."); + } + var accessToken = await ResolveAccessTokenAsync(target, cancellationToken).ConfigureAwait(false); + + using var request = new HttpRequestMessage(HttpMethod.Post, target.TargetUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + return new TeamsDeliveryResult { + DeliveryMethod = target.DeliveryMethod, + TargetUri = target.TargetUri, + IsSuccessStatusCode = response.IsSuccessStatusCode, + StatusCode = (int)response.StatusCode, + ResponseBody = responseBody + }; + } + + public void Dispose() { + if (_disposeHttpClient) { + _httpClient.Dispose(); + } + } + + private static async Task ResolveAccessTokenAsync(TeamsMessageTarget target, CancellationToken cancellationToken) { + if (!string.IsNullOrWhiteSpace(target.AccessToken)) { + return target.AccessToken!; + } + + if (target.AccessTokenProvider is not null) { + var token = await target.AccessTokenProvider(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(token)) { + return token; + } + } + + throw new InvalidOperationException("Graph targets require an access token."); + } +} diff --git a/TeamsX/TeamsAdaptiveAction.cs b/TeamsX/TeamsAdaptiveAction.cs index 0520964..2bf26cd 100644 --- a/TeamsX/TeamsAdaptiveAction.cs +++ b/TeamsX/TeamsAdaptiveAction.cs @@ -3,5 +3,6 @@ namespace TeamsX; public abstract class TeamsAdaptiveAction { public abstract string Type { get; } + public string? Id { get; set; } public string Title { get; set; } = string.Empty; } diff --git a/TeamsX/TeamsAdaptiveCard.cs b/TeamsX/TeamsAdaptiveCard.cs index 3be98aa..67c6b81 100644 --- a/TeamsX/TeamsAdaptiveCard.cs +++ b/TeamsX/TeamsAdaptiveCard.cs @@ -4,6 +4,15 @@ public sealed class TeamsAdaptiveCard { public string Schema { get; set; } = "http://adaptivecards.io/schemas/adaptive-card.json"; public string Type { get; set; } = "AdaptiveCard"; public string Version { get; set; } = "1.2"; + public string? FallbackText { get; set; } + public string? MinimumHeight { get; set; } + public string? Speak { get; set; } + public string? Language { get; set; } + public string? VerticalContentAlignment { get; set; } + public Dictionary? BackgroundImage { get; set; } + public TeamsAdaptiveAction? SelectAction { get; set; } + public bool? AllowImageExpand { get; set; } + public bool FullWidth { get; set; } public List Body { get; } = new(); public List Actions { get; } = new(); public List Mentions { get; } = new(); diff --git a/TeamsX/TeamsAdaptiveColumn.cs b/TeamsX/TeamsAdaptiveColumn.cs index 7041982..1d865a5 100644 --- a/TeamsX/TeamsAdaptiveColumn.cs +++ b/TeamsX/TeamsAdaptiveColumn.cs @@ -4,5 +4,14 @@ public sealed class TeamsAdaptiveColumn : TeamsAdaptiveCardElement { public override string Type => "Column"; public string? Width { get; set; } + public string? Height { get; set; } + public string? MinimumHeight { get; set; } + public string? HorizontalAlignment { get; set; } + public string? VerticalContentAlignment { get; set; } + public string? Spacing { get; set; } + public string? Style { get; set; } + public bool? IsVisible { get; set; } + public bool? Separator { get; set; } + public TeamsAdaptiveAction? SelectAction { get; set; } public List Items { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveColumnSet.cs b/TeamsX/TeamsAdaptiveColumnSet.cs index 7fef0bd..55fd8cf 100644 --- a/TeamsX/TeamsAdaptiveColumnSet.cs +++ b/TeamsX/TeamsAdaptiveColumnSet.cs @@ -3,5 +3,12 @@ namespace TeamsX; public sealed class TeamsAdaptiveColumnSet : TeamsAdaptiveCardElement { public override string Type => "ColumnSet"; + public string? Style { get; set; } + public string? MinimumHeight { get; set; } + public bool? Bleed { get; set; } + public string? Spacing { get; set; } + public bool? Separator { get; set; } + public string? HorizontalAlignment { get; set; } + public string? Height { get; set; } public List Columns { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveContainer.cs b/TeamsX/TeamsAdaptiveContainer.cs index 10a2c52..ae6e71b 100644 --- a/TeamsX/TeamsAdaptiveContainer.cs +++ b/TeamsX/TeamsAdaptiveContainer.cs @@ -3,5 +3,17 @@ namespace TeamsX; public sealed class TeamsAdaptiveContainer : TeamsAdaptiveCardElement { public override string Type => "Container"; + public string? Id { get; set; } + public string? Spacing { get; set; } + public bool? Separator { get; set; } + public string? HorizontalAlignment { get; set; } + public string? Height { get; set; } + public string? Style { get; set; } + public string? MinimumHeight { get; set; } + public bool? Bleed { get; set; } + public string? VerticalContentAlignment { get; set; } + public bool? IsVisible { get; set; } + public Dictionary? BackgroundImage { get; set; } + public TeamsAdaptiveAction? SelectAction { get; set; } public List Items { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveFactSet.cs b/TeamsX/TeamsAdaptiveFactSet.cs index f93644e..9d9dbf0 100644 --- a/TeamsX/TeamsAdaptiveFactSet.cs +++ b/TeamsX/TeamsAdaptiveFactSet.cs @@ -3,5 +3,8 @@ namespace TeamsX; public sealed class TeamsAdaptiveFactSet : TeamsAdaptiveCardElement { public override string Type => "FactSet"; + public string? Height { get; set; } + public string? Spacing { get; set; } + public bool? Separator { get; set; } public List Facts { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveImage.cs b/TeamsX/TeamsAdaptiveImage.cs index 25b3e82..309b497 100644 --- a/TeamsX/TeamsAdaptiveImage.cs +++ b/TeamsX/TeamsAdaptiveImage.cs @@ -3,7 +3,17 @@ namespace TeamsX; public sealed class TeamsAdaptiveImage : TeamsAdaptiveCardElement { public override string Type => "Image"; + public string? Id { get; set; } public string Url { get; set; } = string.Empty; public string? AltText { get; set; } public string? Size { get; set; } + public string? Style { get; set; } + public string? HorizontalAlignment { get; set; } + public string? Height { get; set; } + public string? Width { get; set; } + public string? Spacing { get; set; } + public string? BackgroundColor { get; set; } + public bool? Separator { get; set; } + public bool? IsVisible { get; set; } + public TeamsAdaptiveAction? SelectAction { get; set; } } diff --git a/TeamsX/TeamsAdaptiveImageSet.cs b/TeamsX/TeamsAdaptiveImageSet.cs index 069cdb9..8058e6e 100644 --- a/TeamsX/TeamsAdaptiveImageSet.cs +++ b/TeamsX/TeamsAdaptiveImageSet.cs @@ -3,6 +3,12 @@ namespace TeamsX; public sealed class TeamsAdaptiveImageSet : TeamsAdaptiveCardElement { public override string Type => "ImageSet"; + public string? Id { get; set; } public string? ImageSize { get; set; } + public string? HorizontalAlignment { get; set; } + public string? Height { get; set; } + public string? Spacing { get; set; } + public bool? Separator { get; set; } + public bool? IsVisible { get; set; } public List Images { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveRichTextBlock.cs b/TeamsX/TeamsAdaptiveRichTextBlock.cs index 0d1edd7..4028a80 100644 --- a/TeamsX/TeamsAdaptiveRichTextBlock.cs +++ b/TeamsX/TeamsAdaptiveRichTextBlock.cs @@ -3,5 +3,11 @@ namespace TeamsX; public sealed class TeamsAdaptiveRichTextBlock : TeamsAdaptiveCardElement { public override string Type => "RichTextBlock"; + public string? Id { get; set; } + public string? HorizontalAlignment { get; set; } + public string? Height { get; set; } + public string? Spacing { get; set; } + public bool? Separator { get; set; } + public bool? IsVisible { get; set; } public List Inlines { get; } = new(); } diff --git a/TeamsX/TeamsAdaptiveShowCardAction.cs b/TeamsX/TeamsAdaptiveShowCardAction.cs new file mode 100644 index 0000000..2cccd33 --- /dev/null +++ b/TeamsX/TeamsAdaptiveShowCardAction.cs @@ -0,0 +1,7 @@ +namespace TeamsX; + +public sealed class TeamsAdaptiveShowCardAction : TeamsAdaptiveAction { + public override string Type => "Action.ShowCard"; + + public Dictionary? Card { get; set; } +} diff --git a/TeamsX/TeamsAdaptiveSubmitAction.cs b/TeamsX/TeamsAdaptiveSubmitAction.cs new file mode 100644 index 0000000..73686b6 --- /dev/null +++ b/TeamsX/TeamsAdaptiveSubmitAction.cs @@ -0,0 +1,5 @@ +namespace TeamsX; + +public sealed class TeamsAdaptiveSubmitAction : TeamsAdaptiveAction { + public override string Type => "Action.Submit"; +} diff --git a/TeamsX/TeamsAdaptiveTextBlock.cs b/TeamsX/TeamsAdaptiveTextBlock.cs index 0f35350..47f4af0 100644 --- a/TeamsX/TeamsAdaptiveTextBlock.cs +++ b/TeamsX/TeamsAdaptiveTextBlock.cs @@ -4,8 +4,20 @@ public sealed class TeamsAdaptiveTextBlock : TeamsAdaptiveCardElement { public override string Type => "TextBlock"; public string Text { get; set; } = string.Empty; - public bool Wrap { get; set; } = true; + public string? Id { get; set; } + public string? Spacing { get; set; } + public string? HorizontalAlignment { get; set; } public string? Size { get; set; } public string? Weight { get; set; } public string? Color { get; set; } + public string? Height { get; set; } + public string? FontType { get; set; } + public bool? Highlight { get; set; } + public bool? Italic { get; set; } + public bool? StrikeThrough { get; set; } + public int? MaximumLines { get; set; } + public bool? Separator { get; set; } + public bool? Wrap { get; set; } + public bool? Subtle { get; set; } + public bool? IsVisible { get; set; } } diff --git a/TeamsX/TeamsCardButton.cs b/TeamsX/TeamsCardButton.cs new file mode 100644 index 0000000..ffa8107 --- /dev/null +++ b/TeamsX/TeamsCardButton.cs @@ -0,0 +1,11 @@ +namespace TeamsX; + +/// +/// Represents a wrapper-card button. +/// +public sealed class TeamsCardButton { + public TeamsCardButtonActionType Type { get; set; } + public string? Title { get; set; } + public string? Value { get; set; } + public string? Image { get; set; } +} diff --git a/TeamsX/TeamsCardButtonActionType.cs b/TeamsX/TeamsCardButtonActionType.cs new file mode 100644 index 0000000..2a1c7f1 --- /dev/null +++ b/TeamsX/TeamsCardButtonActionType.cs @@ -0,0 +1,10 @@ +namespace TeamsX; + +/// +/// Supported action types for Teams wrapper-card buttons. +/// +public enum TeamsCardButtonActionType { + ImBack, + OpenUrl, + File +} diff --git a/TeamsX/TeamsCardImage.cs b/TeamsX/TeamsCardImage.cs new file mode 100644 index 0000000..a931845 --- /dev/null +++ b/TeamsX/TeamsCardImage.cs @@ -0,0 +1,9 @@ +namespace TeamsX; + +/// +/// Represents a HeroCard/ThumbnailCard image entry. +/// +public sealed class TeamsCardImage { + public string? Url { get; set; } + public string? Alt { get; set; } +} diff --git a/TeamsX/TeamsClient.cs b/TeamsX/TeamsClient.cs index 2da017f..7fd7c1c 100644 --- a/TeamsX/TeamsClient.cs +++ b/TeamsX/TeamsClient.cs @@ -1,12 +1,12 @@ namespace TeamsX; public sealed class TeamsClient { - public static TeamsClient Default { get; } = new(new ITeamsMessageSender[] { WebhookTeamsMessageSender.Shared }); + public static TeamsClient Default { get; } = new(new ITeamsMessageSender[] { WebhookTeamsMessageSender.Shared, GraphTeamsMessageSender.Shared }); private readonly IReadOnlyList _senders; public TeamsClient() - : this(new ITeamsMessageSender[] { WebhookTeamsMessageSender.Shared }) { + : this(new ITeamsMessageSender[] { WebhookTeamsMessageSender.Shared, GraphTeamsMessageSender.Shared }) { } public TeamsClient(IEnumerable senders) { @@ -36,6 +36,39 @@ public Task SendAsync( return sender.SendAsync(message, target, cancellationToken); } + public Task SendAsync( + TeamsHeroCard card, + TeamsMessageTarget target, + CancellationToken cancellationToken = default) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + return SendWrapperCardAsync(TeamsWrapperCardRenderer.Render(card), target, cancellationToken); + } + + public Task SendAsync( + TeamsThumbnailCard card, + TeamsMessageTarget target, + CancellationToken cancellationToken = default) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + return SendWrapperCardAsync(TeamsWrapperCardRenderer.Render(card), target, cancellationToken); + } + + public Task SendAsync( + TeamsListCard card, + TeamsMessageTarget target, + CancellationToken cancellationToken = default) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + return SendWrapperCardAsync(TeamsWrapperCardRenderer.Render(card), target, cancellationToken); + } + public Task SendJsonAsync( string jsonBody, TeamsMessageTarget target, @@ -56,4 +89,21 @@ public Task SendJsonAsync( return sender.SendJsonAsync(jsonBody, target, cancellationToken); } + + private Task SendWrapperCardAsync( + string attachmentBodyJson, + TeamsMessageTarget target, + CancellationToken cancellationToken) { + if (target is null) { + throw new ArgumentNullException(nameof(target)); + } + + if (target.DeliveryMethod is not TeamsDeliveryMethod.IncomingWebhook and not TeamsDeliveryMethod.WorkflowWebhook) { + throw new InvalidOperationException( + $"Typed wrapper cards are currently supported only for incoming and workflow webhooks. Delivery method '{target.DeliveryMethod}' is not supported."); + } + + var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBodyJson); + return SendJsonAsync(wrappedBody, target, cancellationToken); + } } diff --git a/TeamsX/TeamsColorUtility.cs b/TeamsX/TeamsColorUtility.cs new file mode 100644 index 0000000..e4855fc --- /dev/null +++ b/TeamsX/TeamsColorUtility.cs @@ -0,0 +1,41 @@ +using System.Drawing; +namespace TeamsX; + +/// +/// Normalizes named and hexadecimal color values into Teams theme-color format. +/// +public static class TeamsColorUtility { + public static string? NormalizeToHex(string? color) { + if (string.IsNullOrWhiteSpace(color)) { + return null; + } + + var candidate = color.Trim(); + if (candidate.StartsWith("#", StringComparison.Ordinal)) { + return IsHexColor(candidate) + ? candidate.ToUpperInvariant() + : throw new ArgumentException("The Input value is not a valid colorname nor an valid color hex code.", nameof(color)); + } + + var resolved = Color.FromName(candidate); + if (resolved.ToArgb() == 0 && + !string.Equals(candidate, "Transparent", StringComparison.OrdinalIgnoreCase)) { + throw new ArgumentException("The Input value is not a valid colorname nor an valid color hex code.", nameof(color)); + } + return $"#{resolved.R:X2}{resolved.G:X2}{resolved.B:X2}"; + } + + private static bool IsHexColor(string value) { + if (value.Length != 7 || value[0] != '#') { + return false; + } + + for (var index = 1; index < value.Length; index++) { + if (!Uri.IsHexDigit(value[index])) { + return false; + } + } + + return true; + } +} diff --git a/TeamsX/TeamsHeroCard.cs b/TeamsX/TeamsHeroCard.cs new file mode 100644 index 0000000..1e3aa79 --- /dev/null +++ b/TeamsX/TeamsHeroCard.cs @@ -0,0 +1,12 @@ +namespace TeamsX; + +/// +/// Represents a Teams hero card attachment body. +/// +public sealed class TeamsHeroCard { + public string? Title { get; set; } + public string? SubTitle { get; set; } + public string? Text { get; set; } + public IList Images { get; } = new List(); + public IList Buttons { get; } = new List(); +} diff --git a/TeamsX/TeamsImageDataUtility.cs b/TeamsX/TeamsImageDataUtility.cs new file mode 100644 index 0000000..4413778 --- /dev/null +++ b/TeamsX/TeamsImageDataUtility.cs @@ -0,0 +1,15 @@ +namespace TeamsX; + +/// +/// Builds inline data-URL payloads for embedded Teams images. +/// +public static class TeamsImageDataUtility { + public static string FromFile(string path) { + if (string.IsNullOrWhiteSpace(path)) { + throw new ArgumentException("Image path must not be empty.", nameof(path)); + } + + var bytes = File.ReadAllBytes(path); + return $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } +} diff --git a/TeamsX/TeamsLegacyAdaptiveNormalizer.cs b/TeamsX/TeamsLegacyAdaptiveNormalizer.cs new file mode 100644 index 0000000..10f3f32 --- /dev/null +++ b/TeamsX/TeamsLegacyAdaptiveNormalizer.cs @@ -0,0 +1,305 @@ +using System.Collections; + +namespace TeamsX; + +/// +/// Converts TeamsX adaptive object graphs into legacy dictionary-based payloads that the script module can serialize. +/// +public static class TeamsLegacyAdaptiveNormalizer { + public static object? Normalize(object? value) { + if (value is null) { + return null; + } + + var psObjectType = value.GetType(); + if (string.Equals(psObjectType.FullName, "System.Management.Automation.PSObject", StringComparison.Ordinal)) { + var baseObject = psObjectType.GetProperty("BaseObject")?.GetValue(value); + return Normalize(baseObject); + } + + if (value is IDictionary dictionary) { + var normalized = new Dictionary(); + foreach (DictionaryEntry entry in dictionary) { + normalized[entry.Key?.ToString() ?? string.Empty] = Normalize(entry.Value); + } + + return normalized; + } + + if (value is IEnumerable enumerable && value is not string) { + var items = new List(); + foreach (var item in enumerable) { + items.Add(Normalize(item)); + } + + return items; + } + + if (value is TeamsAdaptiveCard card) { + return new Dictionary { + ["$schema"] = card.Schema, + ["type"] = card.Type, + ["version"] = card.Version, + ["body"] = Normalize(card.Body), + ["actions"] = Normalize(card.Actions), + ["fallbackText"] = EmptyToNull(card.FallbackText), + ["minHeight"] = EmptyToNull(card.MinimumHeight), + ["speak"] = EmptyToNull(card.Speak), + ["lang"] = EmptyToNull(card.Language), + ["verticalContentAlignment"] = EmptyToNull(card.VerticalContentAlignment), + ["backgroundImage"] = Normalize(card.BackgroundImage), + ["selectAction"] = Normalize(card.SelectAction), + ["msteams"] = BuildMsTeams(card) + }; + } + + if (value is TeamsAdaptiveTextBlock textBlock) { + return new Dictionary { + ["type"] = textBlock.Type, + ["text"] = textBlock.Text, + ["id"] = EmptyToNull(textBlock.Id), + ["spacing"] = EmptyToNull(textBlock.Spacing), + ["horizontalAlignment"] = EmptyToNull(textBlock.HorizontalAlignment), + ["size"] = EmptyToNull(textBlock.Size), + ["weight"] = EmptyToNull(textBlock.Weight), + ["color"] = EmptyToNull(textBlock.Color), + ["height"] = EmptyToNull(textBlock.Height), + ["fontType"] = EmptyToNull(textBlock.FontType), + ["highlight"] = textBlock.Highlight, + ["italic"] = textBlock.Italic, + ["strikeThrough"] = textBlock.StrikeThrough, + ["maxLines"] = textBlock.MaximumLines, + ["separator"] = textBlock.Separator, + ["wrap"] = textBlock.Wrap, + ["isSubtle"] = textBlock.Subtle, + ["isVisible"] = textBlock.IsVisible, + }; + } + + if (value is TeamsAdaptiveImage image) { + return new Dictionary { + ["type"] = image.Type, + ["id"] = EmptyToNull(image.Id), + ["url"] = image.Url, + ["size"] = EmptyToNull(image.Size), + ["alt"] = EmptyToNull(image.AltText), + ["style"] = EmptyToNull(image.Style), + ["horizontalAlignment"] = EmptyToNull(image.HorizontalAlignment), + ["height"] = EmptyToNull(image.Height), + ["width"] = EmptyToNull(image.Width), + ["spacing"] = EmptyToNull(image.Spacing), + ["backgroundColor"] = EmptyToNull(image.BackgroundColor), + ["separator"] = image.Separator, + ["isVisible"] = image.IsVisible, + ["selectAction"] = Normalize(image.SelectAction) + }; + } + + if (value is TeamsAdaptiveMedia media) { + return new Dictionary { + ["type"] = media.Type, + ["poster"] = EmptyToNull(media.Poster), + ["id"] = EmptyToNull(media.Id), + ["altText"] = EmptyToNull(media.AltText), + ["horizontalAlignment"] = EmptyToNull(media.HorizontalAlignment), + ["height"] = EmptyToNull(media.Height), + ["spacing"] = EmptyToNull(media.Spacing), + ["separator"] = media.Separator, + ["isVisible"] = media.IsVisible, + ["sources"] = Normalize(media.Sources) + }; + } + + if (value is TeamsAdaptiveMediaSource mediaSource) { + return new Dictionary { + ["mimeType"] = EmptyToNull(mediaSource.MimeType), + ["url"] = EmptyToNull(mediaSource.Url) + }; + } + + if (value is TeamsAdaptiveImageSet imageSet) { + return new Dictionary { + ["type"] = imageSet.Type, + ["id"] = EmptyToNull(imageSet.Id), + ["imageSize"] = EmptyToNull(imageSet.ImageSize), + ["horizontalAlignment"] = EmptyToNull(imageSet.HorizontalAlignment), + ["height"] = EmptyToNull(imageSet.Height), + ["spacing"] = EmptyToNull(imageSet.Spacing), + ["separator"] = imageSet.Separator, + ["isVisible"] = imageSet.IsVisible, + ["images"] = Normalize(imageSet.Images) + }; + } + + if (value is TeamsAdaptiveFactSet factSet) { + return new Dictionary { + ["type"] = factSet.Type, + ["height"] = EmptyToNull(factSet.Height), + ["spacing"] = EmptyToNull(factSet.Spacing), + ["separator"] = factSet.Separator, + ["facts"] = Normalize(factSet.Facts) + }; + } + + if (value is TeamsAdaptiveFact fact) { + return new Dictionary { + ["title"] = EmptyToNull(fact.Title), + ["value"] = EmptyToNull(fact.Value) + }; + } + + if (value is TeamsAdaptiveContainer container) { + return new Dictionary { + ["type"] = container.Type, + ["id"] = EmptyToNull(container.Id), + ["style"] = EmptyToNull(container.Style), + ["verticalContentAlignment"] = EmptyToNull(container.VerticalContentAlignment), + ["horizontalAlignment"] = EmptyToNull(container.HorizontalAlignment), + ["height"] = EmptyToNull(container.Height), + ["spacing"] = EmptyToNull(container.Spacing), + ["bleed"] = container.Bleed, + ["minHeight"] = EmptyToNull(container.MinimumHeight), + ["separator"] = container.Separator, + ["isVisible"] = container.IsVisible, + ["backgroundImage"] = Normalize(container.BackgroundImage), + ["selectAction"] = Normalize(container.SelectAction), + ["items"] = Normalize(container.Items) + }; + } + + if (value is TeamsAdaptiveColumn column) { + return new Dictionary { + ["type"] = column.Type, + ["width"] = EmptyToNull(column.Width), + ["height"] = EmptyToNull(column.Height), + ["minHeight"] = EmptyToNull(column.MinimumHeight), + ["horizontalAlignment"] = EmptyToNull(column.HorizontalAlignment), + ["verticalContentAlignment"] = EmptyToNull(column.VerticalContentAlignment), + ["spacing"] = EmptyToNull(column.Spacing), + ["style"] = EmptyToNull(column.Style), + ["isVisible"] = column.IsVisible, + ["separator"] = column.Separator, + ["selectAction"] = Normalize(column.SelectAction), + ["items"] = Normalize(column.Items) + }; + } + + if (value is TeamsAdaptiveColumnSet columnSet) { + return new Dictionary { + ["type"] = columnSet.Type, + ["style"] = EmptyToNull(columnSet.Style), + ["minHeight"] = EmptyToNull(columnSet.MinimumHeight), + ["bleed"] = columnSet.Bleed, + ["horizontalAlignment"] = EmptyToNull(columnSet.HorizontalAlignment), + ["height"] = EmptyToNull(columnSet.Height), + ["spacing"] = EmptyToNull(columnSet.Spacing), + ["separator"] = columnSet.Separator, + ["columns"] = Normalize(columnSet.Columns) + }; + } + + if (value is TeamsAdaptiveRichTextBlock richTextBlock) { + return new Dictionary { + ["type"] = richTextBlock.Type, + ["id"] = EmptyToNull(richTextBlock.Id), + ["horizontalAlignment"] = EmptyToNull(richTextBlock.HorizontalAlignment), + ["height"] = EmptyToNull(richTextBlock.Height), + ["spacing"] = EmptyToNull(richTextBlock.Spacing), + ["separator"] = richTextBlock.Separator, + ["isVisible"] = richTextBlock.IsVisible, + ["inlines"] = Normalize(richTextBlock.Inlines) + }; + } + + if (value is TeamsAdaptiveTextRun textRun) { + return new Dictionary { + ["type"] = "TextRun", + ["text"] = textRun.Text, + ["color"] = EmptyToNull(textRun.Color), + ["subtle"] = textRun.Subtle, + ["size"] = EmptyToNull(textRun.Size), + ["weight"] = EmptyToNull(textRun.Weight), + ["highlight"] = textRun.Highlight, + ["italic"] = textRun.Italic, + ["strikethrough"] = textRun.StrikeThrough, + ["fontType"] = EmptyToNull(textRun.FontType) + }; + } + + if (value is TeamsAdaptiveActionSet actionSet) { + return new Dictionary { + ["type"] = actionSet.Type, + ["actions"] = Normalize(actionSet.Actions) + }; + } + + if (value is TeamsAdaptiveOpenUrlAction openUrlAction) { + return new Dictionary { + ["type"] = openUrlAction.Type, + ["id"] = EmptyToNull(openUrlAction.Id), + ["title"] = EmptyToNull(openUrlAction.Title), + ["url"] = EmptyToNull(openUrlAction.Url) + }; + } + + if (value is TeamsAdaptiveToggleVisibilityAction toggleVisibilityAction) { + return new Dictionary { + ["type"] = toggleVisibilityAction.Type, + ["id"] = EmptyToNull(toggleVisibilityAction.Id), + ["title"] = EmptyToNull(toggleVisibilityAction.Title), + ["targetElements"] = Normalize(toggleVisibilityAction.TargetElements) + }; + } + + if (value is TeamsAdaptiveSubmitAction submitAction) { + return new Dictionary { + ["type"] = submitAction.Type, + ["id"] = EmptyToNull(submitAction.Id), + ["title"] = EmptyToNull(submitAction.Title) + }; + } + + if (value is TeamsAdaptiveShowCardAction showCardAction) { + return new Dictionary { + ["type"] = showCardAction.Type, + ["id"] = EmptyToNull(showCardAction.Id), + ["title"] = EmptyToNull(showCardAction.Title), + ["card"] = Normalize(showCardAction.Card) + }; + } + + if (value is TeamsAdaptiveMention mention) { + return new Dictionary { + ["type"] = mention.Type, + ["text"] = mention.Text, + ["mentioned"] = new Dictionary { + ["id"] = mention.Mentioned.Id, + ["name"] = EmptyToNull(mention.Mentioned.Name) + } + }; + } + + return value; + } + + private static Dictionary? BuildMsTeams(TeamsAdaptiveCard card) { + var payload = new Dictionary(); + if (card.AllowImageExpand is not null) { + payload["allowExpand"] = card.AllowImageExpand; + } + + if (card.FullWidth) { + payload["width"] = "Full"; + } + + if (card.Mentions.Count > 0) { + payload["entities"] = Normalize(card.Mentions); + } + + return payload.Count == 0 ? null : payload; + } + + private static string? EmptyToNull(string? value) { + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/TeamsX/TeamsListCard.cs b/TeamsX/TeamsListCard.cs new file mode 100644 index 0000000..757e362 --- /dev/null +++ b/TeamsX/TeamsListCard.cs @@ -0,0 +1,10 @@ +namespace TeamsX; + +/// +/// Represents a Teams list card attachment body. +/// +public sealed class TeamsListCard { + public string? Title { get; set; } + public IList Items { get; } = new List(); + public IList Buttons { get; } = new List(); +} diff --git a/TeamsX/TeamsListCardItem.cs b/TeamsX/TeamsListCardItem.cs new file mode 100644 index 0000000..5384fb9 --- /dev/null +++ b/TeamsX/TeamsListCardItem.cs @@ -0,0 +1,14 @@ +namespace TeamsX; + +/// +/// Represents one Teams list-card item. +/// +public sealed class TeamsListCardItem { + public TeamsListCardItemKind Kind { get; set; } + public string? Icon { get; set; } + public string? Title { get; set; } + public string? SubTitle { get; set; } + public string? TapAction { get; set; } + public TeamsCardButtonActionType? TapType { get; set; } + public string? TapValue { get; set; } +} diff --git a/TeamsX/TeamsListCardItemKind.cs b/TeamsX/TeamsListCardItemKind.cs new file mode 100644 index 0000000..95fbccd --- /dev/null +++ b/TeamsX/TeamsListCardItemKind.cs @@ -0,0 +1,11 @@ +namespace TeamsX; + +/// +/// Supported Teams list-card item types. +/// +public enum TeamsListCardItemKind { + File, + ResultItem, + Section, + Person +} diff --git a/TeamsX/TeamsMessageButton.cs b/TeamsX/TeamsMessageButton.cs new file mode 100644 index 0000000..1ddc871 --- /dev/null +++ b/TeamsX/TeamsMessageButton.cs @@ -0,0 +1,10 @@ +namespace TeamsX; + +/// +/// Represents a connector-card action button. +/// +public sealed class TeamsMessageButton { + public string? Name { get; set; } + public string? Link { get; set; } + public TeamsMessageButtonType ButtonType { get; set; } = TeamsMessageButtonType.ViewAction; +} diff --git a/TeamsX/TeamsMessageButtonType.cs b/TeamsX/TeamsMessageButtonType.cs new file mode 100644 index 0000000..bd1ad25 --- /dev/null +++ b/TeamsX/TeamsMessageButtonType.cs @@ -0,0 +1,12 @@ +namespace TeamsX; + +/// +/// Defines the supported Office 365 connector-card button payloads. +/// +public enum TeamsMessageButtonType { + ViewAction, + TextInput, + DateInput, + HttpPost, + OpenUri +} diff --git a/TeamsX/TeamsMessageFact.cs b/TeamsX/TeamsMessageFact.cs new file mode 100644 index 0000000..9fb2d5d --- /dev/null +++ b/TeamsX/TeamsMessageFact.cs @@ -0,0 +1,9 @@ +namespace TeamsX; + +/// +/// Represents a connector-card fact entry. +/// +public sealed class TeamsMessageFact { + public string? Name { get; set; } + public string? Value { get; set; } +} diff --git a/TeamsX/TeamsMessageImage.cs b/TeamsX/TeamsMessageImage.cs new file mode 100644 index 0000000..ed994d2 --- /dev/null +++ b/TeamsX/TeamsMessageImage.cs @@ -0,0 +1,9 @@ +namespace TeamsX; + +/// +/// Represents an image entry attached to a connector-card section. +/// +public sealed class TeamsMessageImage { + public string? Image { get; set; } + public bool IsHeroImage { get; set; } +} diff --git a/TeamsX/TeamsMessageListItem.cs b/TeamsX/TeamsMessageListItem.cs new file mode 100644 index 0000000..63d650a --- /dev/null +++ b/TeamsX/TeamsMessageListItem.cs @@ -0,0 +1,12 @@ +namespace TeamsX; + +/// +/// Represents a legacy list item that is eventually rendered as connector-card fact text. +/// +public sealed class TeamsMessageListItem { + public string? Text { get; set; } + + public int Level { get; set; } + + public bool Numbered { get; set; } +} diff --git a/TeamsX/TeamsMessageRequest.cs b/TeamsX/TeamsMessageRequest.cs index 4e303ac..731190d 100644 --- a/TeamsX/TeamsMessageRequest.cs +++ b/TeamsX/TeamsMessageRequest.cs @@ -4,7 +4,11 @@ public sealed class TeamsMessageRequest { public string? Title { get; set; } public string? Text { get; set; } public string? Summary { get; set; } + public string? ThemeColor { get; set; } + public bool HideOriginalBody { get; set; } + public bool UseConnectorCardFormat { get; set; } public TeamsAdaptiveCard? AdaptiveCard { get; set; } + public IList Sections { get; } = new List(); public string EffectiveSummary { get { diff --git a/TeamsX/TeamsMessageSection.cs b/TeamsX/TeamsMessageSection.cs new file mode 100644 index 0000000..48bc09c --- /dev/null +++ b/TeamsX/TeamsMessageSection.cs @@ -0,0 +1,19 @@ +namespace TeamsX; + +/// +/// Represents a connector-card section. +/// +public sealed class TeamsMessageSection { + public string? Title { get; set; } + public string? ActivityTitle { get; set; } + public string? ActivitySubtitle { get; set; } + public string? ActivityImage { get; set; } + public string? ActivityText { get; set; } + public string? Text { get; set; } + public bool StartGroup { get; set; } + + public IList Facts { get; } = new List(); + public IList Buttons { get; } = new List(); + public IList Images { get; } = new List(); + public IList HeroImages { get; } = new List(); +} diff --git a/TeamsX/TeamsMessageSectionDirective.cs b/TeamsX/TeamsMessageSectionDirective.cs new file mode 100644 index 0000000..75bd9fa --- /dev/null +++ b/TeamsX/TeamsMessageSectionDirective.cs @@ -0,0 +1,9 @@ +namespace TeamsX; + +/// +/// Represents a typed instruction that sets one section activity property. +/// +public sealed class TeamsMessageSectionDirective { + public TeamsMessageSectionDirectiveType DirectiveType { get; set; } + public string? Value { get; set; } +} diff --git a/TeamsX/TeamsMessageSectionDirectiveType.cs b/TeamsX/TeamsMessageSectionDirectiveType.cs new file mode 100644 index 0000000..eeda708 --- /dev/null +++ b/TeamsX/TeamsMessageSectionDirectiveType.cs @@ -0,0 +1,11 @@ +namespace TeamsX; + +/// +/// Identifies which section property a directive updates. +/// +public enum TeamsMessageSectionDirectiveType { + ActivityTitle, + ActivitySubtitle, + ActivityText, + ActivityImage +} diff --git a/TeamsX/TeamsMessageTarget.cs b/TeamsX/TeamsMessageTarget.cs index e1ad84a..869f845 100644 --- a/TeamsX/TeamsMessageTarget.cs +++ b/TeamsX/TeamsMessageTarget.cs @@ -4,6 +4,9 @@ public sealed class TeamsMessageTarget { public TeamsDeliveryMethod DeliveryMethod { get; set; } public Uri TargetUri { get; set; } = null!; public string? DisplayName { get; set; } + public string? AccessToken { get; set; } + internal Func>? AccessTokenProvider { get; set; } + public bool HasDynamicAccessToken => AccessTokenProvider is not null; public static TeamsMessageTarget ForIncomingWebhook(Uri uri, string? displayName = null) { ValidateUri(uri); @@ -25,6 +28,86 @@ public static TeamsMessageTarget ForWorkflowWebhook(Uri uri, string? displayName }; } + public static TeamsMessageTarget ForGraphChannelMessage( + string teamId, + string channelId, + string accessToken, + string? displayName = null, + Uri? graphBaseUri = null) { + ValidateIdentifier(teamId, nameof(teamId)); + ValidateIdentifier(channelId, nameof(channelId)); + ValidateAccessToken(accessToken); + + var baseUri = graphBaseUri ?? new Uri("https://graph.microsoft.com/"); + ValidateUri(baseUri); + + return new TeamsMessageTarget { + DeliveryMethod = TeamsDeliveryMethod.GraphChannelMessage, + TargetUri = new Uri(baseUri, $"v1.0/teams/{Uri.EscapeDataString(teamId)}/channels/{Uri.EscapeDataString(channelId)}/messages"), + DisplayName = displayName, + AccessToken = accessToken + }; + } + + public static TeamsMessageTarget ForGraphChannelMessage( + string teamId, + string channelId, + Func> accessTokenProvider, + string? displayName = null, + Uri? graphBaseUri = null) { + ValidateIdentifier(teamId, nameof(teamId)); + ValidateIdentifier(channelId, nameof(channelId)); + ValidateAccessTokenProvider(accessTokenProvider); + + var baseUri = graphBaseUri ?? new Uri("https://graph.microsoft.com/"); + ValidateUri(baseUri); + + return new TeamsMessageTarget { + DeliveryMethod = TeamsDeliveryMethod.GraphChannelMessage, + TargetUri = new Uri(baseUri, $"v1.0/teams/{Uri.EscapeDataString(teamId)}/channels/{Uri.EscapeDataString(channelId)}/messages"), + DisplayName = displayName, + AccessTokenProvider = accessTokenProvider + }; + } + + public static TeamsMessageTarget ForGraphChatMessage( + string chatId, + string accessToken, + string? displayName = null, + Uri? graphBaseUri = null) { + ValidateIdentifier(chatId, nameof(chatId)); + ValidateAccessToken(accessToken); + + var baseUri = graphBaseUri ?? new Uri("https://graph.microsoft.com/"); + ValidateUri(baseUri); + + return new TeamsMessageTarget { + DeliveryMethod = TeamsDeliveryMethod.GraphChatMessage, + TargetUri = new Uri(baseUri, $"v1.0/chats/{Uri.EscapeDataString(chatId)}/messages"), + DisplayName = displayName, + AccessToken = accessToken + }; + } + + public static TeamsMessageTarget ForGraphChatMessage( + string chatId, + Func> accessTokenProvider, + string? displayName = null, + Uri? graphBaseUri = null) { + ValidateIdentifier(chatId, nameof(chatId)); + ValidateAccessTokenProvider(accessTokenProvider); + + var baseUri = graphBaseUri ?? new Uri("https://graph.microsoft.com/"); + ValidateUri(baseUri); + + return new TeamsMessageTarget { + DeliveryMethod = TeamsDeliveryMethod.GraphChatMessage, + TargetUri = new Uri(baseUri, $"v1.0/chats/{Uri.EscapeDataString(chatId)}/messages"), + DisplayName = displayName, + AccessTokenProvider = accessTokenProvider + }; + } + private static void ValidateUri(Uri uri) { if (uri is null) { throw new ArgumentNullException(nameof(uri)); @@ -34,4 +117,22 @@ private static void ValidateUri(Uri uri) { throw new ArgumentException("Target URI must be absolute.", nameof(uri)); } } + + private static void ValidateIdentifier(string value, string parameterName) { + if (string.IsNullOrWhiteSpace(value)) { + throw new ArgumentException("Value cannot be null or whitespace.", parameterName); + } + } + + private static void ValidateAccessToken(string accessToken) { + if (string.IsNullOrWhiteSpace(accessToken)) { + throw new ArgumentException("Access token cannot be null or whitespace.", nameof(accessToken)); + } + } + + private static void ValidateAccessTokenProvider(Func> accessTokenProvider) { + if (accessTokenProvider is null) { + throw new ArgumentNullException(nameof(accessTokenProvider)); + } + } } diff --git a/TeamsX/TeamsThumbnailCard.cs b/TeamsX/TeamsThumbnailCard.cs new file mode 100644 index 0000000..ad2de5f --- /dev/null +++ b/TeamsX/TeamsThumbnailCard.cs @@ -0,0 +1,12 @@ +namespace TeamsX; + +/// +/// Represents a Teams thumbnail card attachment body. +/// +public sealed class TeamsThumbnailCard { + public string? Title { get; set; } + public string? SubTitle { get; set; } + public string? Text { get; set; } + public IList Images { get; } = new List(); + public IList Buttons { get; } = new List(); +} diff --git a/TeamsX/TeamsWrapperCardRenderer.cs b/TeamsX/TeamsWrapperCardRenderer.cs new file mode 100644 index 0000000..49c7f6e --- /dev/null +++ b/TeamsX/TeamsWrapperCardRenderer.cs @@ -0,0 +1,135 @@ +namespace TeamsX; + +/// +/// Renders Teams HeroCard, ThumbnailCard, and ListCard payloads. +/// +public static class TeamsWrapperCardRenderer { + public static string Render(TeamsHeroCard card) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + var payload = new Dictionary { + ["contentType"] = "application/vnd.microsoft.card.hero", + ["content"] = new Dictionary { + ["title"] = EmptyToNull(card.Title), + ["subTitle"] = EmptyToNull(card.SubTitle), + ["text"] = EmptyToNull(card.Text), + ["images"] = card.Images.Count == 0 ? null : card.Images.Select(RenderImage).ToArray(), + ["buttons"] = card.Buttons.Count == 0 ? null : card.Buttons.Select(RenderButton).ToArray() + } + }; + + return TeamsJsonSerializer.Serialize(payload); + } + + public static string Render(TeamsThumbnailCard card) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + var payload = new Dictionary { + ["contentType"] = "application/vnd.microsoft.card.thumbnail", + ["content"] = new Dictionary { + ["title"] = EmptyToNull(card.Title), + ["subTitle"] = EmptyToNull(card.SubTitle), + ["text"] = EmptyToNull(card.Text), + ["images"] = card.Images.Count == 0 ? null : card.Images.Select(RenderImage).ToArray(), + ["buttons"] = card.Buttons.Count == 0 ? null : card.Buttons.Select(RenderButton).ToArray() + } + }; + + return TeamsJsonSerializer.Serialize(payload); + } + + public static string Render(TeamsListCard card) { + if (card is null) { + throw new ArgumentNullException(nameof(card)); + } + + var payload = new Dictionary { + ["contentType"] = "application/vnd.microsoft.teams.card.list", + ["content"] = new Dictionary { + ["title"] = EmptyToNull(card.Title), + ["items"] = card.Items.Count == 0 ? null : card.Items.Select(RenderListItem).ToArray(), + ["buttons"] = card.Buttons.Count == 0 ? null : card.Buttons.Select(RenderButton).ToArray() + } + }; + + return TeamsJsonSerializer.Serialize(payload); + } + + public static string WrapAsMessage(string attachmentBodyJson) { + if (attachmentBodyJson is null) { + throw new ArgumentNullException(nameof(attachmentBodyJson)); + } + + var trimmedBody = string.IsNullOrWhiteSpace(attachmentBodyJson) + ? "null" + : attachmentBodyJson.Trim(); + + return $"{{\"type\":\"message\",\"attachments\":[{trimmedBody}]}}"; + } + + private static Dictionary RenderImage(TeamsCardImage image) { + return new Dictionary { + ["url"] = EmptyToNull(image.Url), + ["alt"] = EmptyToNull(image.Alt) + }; + } + + private static Dictionary RenderButton(TeamsCardButton button) { + return new Dictionary { + ["type"] = RenderActionType(button.Type), + ["title"] = EmptyToNull(button.Title), + ["value"] = EmptyToNull(button.Value), + ["image"] = EmptyToNull(button.Image) + }; + } + + private static Dictionary RenderListItem(TeamsListCardItem item) { + var tapValue = BuildTapValue(item); + + return new Dictionary { + ["type"] = RenderItemKind(item.Kind), + ["id"] = string.IsNullOrWhiteSpace(item.TapAction) ? null : EmptyToNull(item.TapValue), + ["title"] = EmptyToNull(item.Title), + ["subtitle"] = EmptyToNull(item.SubTitle), + ["icon"] = EmptyToNull(item.Icon), + ["tap"] = item.TapType.HasValue + ? new Dictionary { + ["type"] = RenderActionType(item.TapType.Value), + ["value"] = EmptyToNull(tapValue) + } + : null + }; + } + + private static string? BuildTapValue(TeamsListCardItem item) { + var combined = $"{item.TapAction} {item.TapValue}".Trim(); + return string.IsNullOrWhiteSpace(combined) ? null : combined; + } + + private static string RenderActionType(TeamsCardButtonActionType actionType) { + return actionType switch { + TeamsCardButtonActionType.ImBack => "imBack", + TeamsCardButtonActionType.OpenUrl => "openUrl", + TeamsCardButtonActionType.File => "file", + _ => throw new NotSupportedException($"Wrapper-card action '{actionType}' is not supported.") + }; + } + + private static string RenderItemKind(TeamsListCardItemKind kind) { + return kind switch { + TeamsListCardItemKind.File => "file", + TeamsListCardItemKind.ResultItem => "resultItem", + TeamsListCardItemKind.Section => "section", + TeamsListCardItemKind.Person => "person", + _ => throw new NotSupportedException($"List-card item kind '{kind}' is not supported.") + }; + } + + private static string? EmptyToNull(string? value) { + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/TeamsX/WebhookMessageRenderer.cs b/TeamsX/WebhookMessageRenderer.cs index 8c02a23..4bfb9a3 100644 --- a/TeamsX/WebhookMessageRenderer.cs +++ b/TeamsX/WebhookMessageRenderer.cs @@ -10,6 +10,10 @@ public static string Render(TeamsMessageRequest request) { return RenderAdaptiveCardMessage(request); } + if (request.UseConnectorCardFormat) { + return RenderConnectorCardMessage(request); + } + var payload = new Dictionary { ["summary"] = EmptyToNull(request.EffectiveSummary), ["title"] = EmptyToNull(request.Title), @@ -19,6 +23,21 @@ public static string Render(TeamsMessageRequest request) { return TeamsJsonSerializer.Serialize(payload); } + private static string RenderConnectorCardMessage(TeamsMessageRequest request) { + var payload = new Dictionary { + ["themeColor"] = EmptyToNull(request.ThemeColor), + ["title"] = EmptyToNull(request.Title), + ["hideOriginalBody"] = request.HideOriginalBody ? true : null, + ["summary"] = EmptyToNull(request.EffectiveSummary), + ["text"] = EmptyToNull(request.Text), + ["sections"] = request.Sections.Count == 0 + ? null + : request.Sections.Select(RenderConnectorSection).ToArray() + }; + + return TeamsJsonSerializer.Serialize(payload); + } + private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { var payload = new Dictionary { ["type"] = "message", @@ -36,17 +55,36 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { } internal static Dictionary RenderAdaptiveCard(TeamsAdaptiveCard card) { + Dictionary? msTeams = null; + if (card.AllowImageExpand is not null || card.FullWidth || card.Mentions.Count > 0) { + msTeams = new Dictionary(); + if (card.AllowImageExpand is not null) { + msTeams["allowExpand"] = card.AllowImageExpand; + } + + if (card.FullWidth) { + msTeams["width"] = "Full"; + } + + if (card.Mentions.Count > 0) { + msTeams["entities"] = card.Mentions.Select(RenderAdaptiveMention).ToArray(); + } + } + var content = new Dictionary { ["$schema"] = card.Schema, ["type"] = card.Type, ["version"] = card.Version, + ["fallbackText"] = EmptyToNull(card.FallbackText), + ["minHeight"] = EmptyToNull(card.MinimumHeight), + ["speak"] = EmptyToNull(card.Speak), + ["lang"] = EmptyToNull(card.Language), + ["verticalContentAlignment"] = EmptyToNull(card.VerticalContentAlignment), + ["backgroundImage"] = card.BackgroundImage, + ["selectAction"] = card.SelectAction is null ? null : RenderAdaptiveAction(card.SelectAction), ["body"] = card.Body.Select(RenderAdaptiveElement).ToArray(), ["actions"] = card.Actions.Count == 0 ? null : card.Actions.Select(RenderAdaptiveAction).ToArray(), - ["msteams"] = card.Mentions.Count == 0 - ? null - : new Dictionary { - ["entities"] = card.Mentions.Select(RenderAdaptiveMention).ToArray() - } + ["msteams"] = msTeams }; return content; @@ -63,21 +101,140 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { }; } + private static Dictionary RenderConnectorSection(TeamsMessageSection section) { + var text = section.Text; + if (section.HeroImages.Count > 0) { + var fragments = new List(section.HeroImages); + if (!string.IsNullOrWhiteSpace(text)) { + fragments.Add(text); + } + + text = string.Join(" ", fragments); + } + + return new Dictionary { + ["title"] = EmptyToNull(section.Title), + ["activityTitle"] = EmptyToNull(section.ActivityTitle), + ["activitySubtitle"] = EmptyToNull(section.ActivitySubtitle), + ["activityImage"] = EmptyToNull(section.ActivityImage), + ["activityText"] = EmptyToNull(section.ActivityText), + ["text"] = EmptyToNull(text), + ["startGroup"] = section.StartGroup ? true : null, + ["facts"] = section.Facts.Count == 0 + ? null + : section.Facts.Select(fact => new Dictionary { + ["name"] = EmptyToNull(fact.Name), + ["value"] = EmptyToNull(fact.Value) + }).ToArray(), + ["potentialAction"] = section.Buttons.Count == 0 + ? null + : section.Buttons.Select(RenderConnectorButton).ToArray(), + ["images"] = section.Images.Count == 0 + ? null + : section.Images.Select(image => new Dictionary { + ["image"] = EmptyToNull(image) + }).ToArray() + }; + } + + private static Dictionary RenderConnectorButton(TeamsMessageButton button) { + return button.ButtonType switch { + TeamsMessageButtonType.ViewAction => new Dictionary { + ["@context"] = "http://schema.org", + ["@type"] = "ViewAction", + ["name"] = EmptyToNull(button.Name), + ["target"] = string.IsNullOrWhiteSpace(button.Link) ? null : new[] { button.Link } + }, + TeamsMessageButtonType.TextInput => new Dictionary { + ["@type"] = "ActionCard", + ["Name"] = EmptyToNull(button.Name), + ["Inputs"] = new[] { + new Dictionary { + ["@type"] = "TextInput", + ["id"] = "Comment", + ["isMultiLine"] = true, + ["title"] = "Enter Your Text Input Here" + } + }, + ["actions"] = new[] { + new Dictionary { + ["@type"] = "HttpPOST", + ["Name"] = "OK", + ["target"] = EmptyToNull(button.Link) + } + } + }, + TeamsMessageButtonType.DateInput => new Dictionary { + ["@type"] = "ActionCard", + ["Name"] = EmptyToNull(button.Name), + ["Inputs"] = new[] { + new Dictionary { + ["@type"] = "DateInput", + ["id"] = "dueDate" + } + }, + ["actions"] = new[] { + new Dictionary { + ["@type"] = "HttpPOST", + ["Name"] = "OK", + ["target"] = EmptyToNull(button.Link) + } + } + }, + TeamsMessageButtonType.HttpPost => new Dictionary { + ["name"] = EmptyToNull(button.Name), + ["@type"] = "HttpPOST", + ["Target"] = EmptyToNull(button.Link) + }, + TeamsMessageButtonType.OpenUri => new Dictionary { + ["name"] = EmptyToNull(button.Name), + ["@type"] = "OpenURI", + ["Targets"] = string.IsNullOrWhiteSpace(button.Link) + ? null + : new[] { + new Dictionary { + ["os"] = "default", + ["uri"] = button.Link + } + } + }, + _ => throw new NotSupportedException($"Connector action '{button.ButtonType}' is not supported by the webhook renderer yet.") + }; + } + private static Dictionary RenderAdaptiveElement(TeamsAdaptiveCardElement element) { if (element is TeamsAdaptiveTextBlock textBlock) { return new Dictionary { ["type"] = textBlock.Type, ["text"] = textBlock.Text, + ["id"] = EmptyToNull(textBlock.Id), + ["spacing"] = EmptyToNull(textBlock.Spacing), + ["horizontalAlignment"] = EmptyToNull(textBlock.HorizontalAlignment), ["wrap"] = textBlock.Wrap, ["size"] = EmptyToNull(textBlock.Size), ["weight"] = EmptyToNull(textBlock.Weight), - ["color"] = EmptyToNull(textBlock.Color) + ["color"] = EmptyToNull(textBlock.Color), + ["height"] = EmptyToNull(textBlock.Height), + ["fontType"] = EmptyToNull(textBlock.FontType), + ["isSubtle"] = textBlock.Subtle, + ["maxLines"] = textBlock.MaximumLines, + ["highlight"] = textBlock.Highlight, + ["italic"] = textBlock.Italic, + ["strikeThrough"] = textBlock.StrikeThrough, + ["separator"] = textBlock.Separator, + ["isVisible"] = textBlock.IsVisible }; } if (element is TeamsAdaptiveRichTextBlock richTextBlock) { return new Dictionary { ["type"] = richTextBlock.Type, + ["id"] = EmptyToNull(richTextBlock.Id), + ["horizontalAlignment"] = EmptyToNull(richTextBlock.HorizontalAlignment), + ["height"] = EmptyToNull(richTextBlock.Height), + ["spacing"] = EmptyToNull(richTextBlock.Spacing), + ["separator"] = richTextBlock.Separator, + ["isVisible"] = richTextBlock.IsVisible, ["inlines"] = richTextBlock.Inlines.Select(inline => new Dictionary { ["type"] = "TextRun", ["text"] = inline.Text, @@ -96,6 +253,9 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (element is TeamsAdaptiveFactSet factSet) { return new Dictionary { ["type"] = factSet.Type, + ["height"] = EmptyToNull(factSet.Height), + ["spacing"] = EmptyToNull(factSet.Spacing), + ["separator"] = factSet.Separator, ["facts"] = factSet.Facts.Select(fact => new Dictionary { ["title"] = fact.Title, ["value"] = fact.Value @@ -106,9 +266,19 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (element is TeamsAdaptiveImage image) { return new Dictionary { ["type"] = image.Type, + ["id"] = EmptyToNull(image.Id), ["url"] = image.Url, ["altText"] = EmptyToNull(image.AltText), - ["size"] = EmptyToNull(image.Size) + ["size"] = EmptyToNull(image.Size), + ["style"] = EmptyToNull(image.Style), + ["horizontalAlignment"] = EmptyToNull(image.HorizontalAlignment), + ["height"] = EmptyToNull(image.Height), + ["width"] = EmptyToNull(image.Width), + ["spacing"] = EmptyToNull(image.Spacing), + ["backgroundColor"] = EmptyToNull(image.BackgroundColor), + ["separator"] = image.Separator, + ["isVisible"] = image.IsVisible, + ["selectAction"] = image.SelectAction is null ? null : RenderAdaptiveAction(image.SelectAction) }; } @@ -133,7 +303,13 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (element is TeamsAdaptiveImageSet imageSet) { return new Dictionary { ["type"] = imageSet.Type, + ["id"] = EmptyToNull(imageSet.Id), ["imageSize"] = EmptyToNull(imageSet.ImageSize), + ["horizontalAlignment"] = EmptyToNull(imageSet.HorizontalAlignment), + ["height"] = EmptyToNull(imageSet.Height), + ["spacing"] = EmptyToNull(imageSet.Spacing), + ["separator"] = imageSet.Separator, + ["isVisible"] = imageSet.IsVisible, ["images"] = imageSet.Images.Select(image => (object?)RenderAdaptiveElement(image)).ToArray() }; } @@ -141,6 +317,18 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (element is TeamsAdaptiveContainer container) { return new Dictionary { ["type"] = container.Type, + ["id"] = EmptyToNull(container.Id), + ["style"] = EmptyToNull(container.Style), + ["verticalContentAlignment"] = EmptyToNull(container.VerticalContentAlignment), + ["horizontalAlignment"] = EmptyToNull(container.HorizontalAlignment), + ["height"] = EmptyToNull(container.Height), + ["spacing"] = EmptyToNull(container.Spacing), + ["bleed"] = container.Bleed, + ["minHeight"] = EmptyToNull(container.MinimumHeight), + ["separator"] = container.Separator, + ["isVisible"] = container.IsVisible, + ["backgroundImage"] = container.BackgroundImage, + ["selectAction"] = container.SelectAction is null ? null : RenderAdaptiveAction(container.SelectAction), ["items"] = container.Items.Select(RenderAdaptiveElement).ToArray() }; } @@ -149,6 +337,15 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { return new Dictionary { ["type"] = column.Type, ["width"] = EmptyToNull(column.Width), + ["height"] = EmptyToNull(column.Height), + ["minHeight"] = EmptyToNull(column.MinimumHeight), + ["horizontalAlignment"] = EmptyToNull(column.HorizontalAlignment), + ["verticalContentAlignment"] = EmptyToNull(column.VerticalContentAlignment), + ["spacing"] = EmptyToNull(column.Spacing), + ["style"] = EmptyToNull(column.Style), + ["isVisible"] = column.IsVisible, + ["separator"] = column.Separator, + ["selectAction"] = column.SelectAction is null ? null : RenderAdaptiveAction(column.SelectAction), ["items"] = column.Items.Select(RenderAdaptiveElement).ToArray() }; } @@ -156,6 +353,13 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (element is TeamsAdaptiveColumnSet columnSet) { return new Dictionary { ["type"] = columnSet.Type, + ["style"] = EmptyToNull(columnSet.Style), + ["minHeight"] = EmptyToNull(columnSet.MinimumHeight), + ["bleed"] = columnSet.Bleed, + ["horizontalAlignment"] = EmptyToNull(columnSet.HorizontalAlignment), + ["height"] = EmptyToNull(columnSet.Height), + ["spacing"] = EmptyToNull(columnSet.Spacing), + ["separator"] = columnSet.Separator, ["columns"] = columnSet.Columns.Select(column => (object?)RenderAdaptiveElement(column)).ToArray() }; } @@ -174,6 +378,7 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (action is TeamsAdaptiveOpenUrlAction openUrlAction) { return new Dictionary { ["type"] = openUrlAction.Type, + ["id"] = EmptyToNull(openUrlAction.Id), ["title"] = EmptyToNull(openUrlAction.Title), ["url"] = openUrlAction.Url }; @@ -182,11 +387,29 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (action is TeamsAdaptiveToggleVisibilityAction toggleVisibilityAction) { return new Dictionary { ["type"] = toggleVisibilityAction.Type, + ["id"] = EmptyToNull(toggleVisibilityAction.Id), ["title"] = EmptyToNull(toggleVisibilityAction.Title), ["targetElements"] = toggleVisibilityAction.TargetElements.ToArray() }; } + if (action is TeamsAdaptiveSubmitAction submitAction) { + return new Dictionary { + ["type"] = submitAction.Type, + ["id"] = EmptyToNull(submitAction.Id), + ["title"] = EmptyToNull(submitAction.Title) + }; + } + + if (action is TeamsAdaptiveShowCardAction showCardAction) { + return new Dictionary { + ["type"] = showCardAction.Type, + ["id"] = EmptyToNull(showCardAction.Id), + ["title"] = EmptyToNull(showCardAction.Title), + ["card"] = TeamsLegacyAdaptiveNormalizer.Normalize(showCardAction.Card) + }; + } + throw new NotSupportedException($"Adaptive action '{action.GetType().Name}' is not supported by the webhook renderer yet."); } diff --git a/Tests/ConvertTo-TeamsFact.Tests.ps1 b/Tests/ConvertTo-TeamsFact.Tests.ps1 deleted file mode 100644 index 3e49849..0000000 --- a/Tests/ConvertTo-TeamsFact.Tests.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -Describe "Conversion of different objects to Teams fact" { - Context "Object types: PSObject, unordered dictionary, ordered dictionary and string" { - It "Convert PSObject to Teams facts" { - $Output = New-Object -TypeName PSObject -Property ([ordered]@{ - Application = 'Microsoft Teams' - Developer = 'Microsoft Corporation' - } - ) | ConvertTo-TeamsFact - $Output | Should -HaveCount 2 - $Output | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' - $Output[0].type | Should -Be 'fact' - } - It "Convert unordered dictionary to Teams facts" { - $Output = @{ - Application = 'Microsoft Teams' - Developer = 'Microsoft Corporation' - } | ConvertTo-TeamsFact - - $Output | Should -HaveCount 2 - $Output | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' - $Output[0].type | Should -Be 'fact' - } - It "Convert ordered dictionary to Teams facts" { - $Output = [ordered]@{ - Application = 'Microsoft Teams' - Developer = 'Microsoft Corporation' - } | ConvertTo-TeamsFact - - $Output | Should -HaveCount 2 - $Output | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' - $Output[0].type | Should -Be 'fact' - } - It "Throw an error when a plain string is passed in" { - { 'My Plain String' | ConvertTo-TeamsFact } | Should -Throw - } - } -} \ No newline at end of file diff --git a/Tests/Send-AdaptiveCard.Tests.ps1 b/Tests/Send-AdaptiveCard.Tests.ps1 deleted file mode 100644 index 5126b24..0000000 --- a/Tests/Send-AdaptiveCard.Tests.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'New-AdaptiveCard - Should send message properly' { - It 'Adaptive Card with nested adapted card' { - $Output = New-AdaptiveCard -Uri $TeamsID { - New-AdaptiveContainer { - New-AdaptiveTextBlock -Text 'Publish Adaptive Card schema' -Weight Bolder -Size Medium - New-AdaptiveColumnSet { - New-AdaptiveColumn -Width auto { - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - } - New-AdaptiveColumn -Width stretch { - New-AdaptiveTextBlock -Text "Matt Hidinger" -Weight Bolder -Wrap - New-AdaptiveTextBlock -Text "Created {{DATE(2017-02-14T06:08:39Z, SHORT)}}" -Subtle -Spacing None -Wrap - } - } - } - New-AdaptiveContainer { - New-AdaptiveTextBlock -Text "Now that we have defined the main rules and features of the format, we need to produce a schema and publish it to GitHub. The schema will be the starting point of our reference documentation." -Wrap - New-AdaptiveFactSet { - New-AdaptiveFact -Title 'Board:' -Value 'Adaptive Card' - New-AdaptiveFact -Title 'List:' -Value 'Backlog' - New-AdaptiveFact -Title 'Assigned to:' -Value 'Matt Hidinger' - New-AdaptiveFact -Title 'Due date:' -Value 'Not set' - } - } - } -Action { - New-AdaptiveAction -Title 'Set due date' -Type Action.Submit - New-AdaptiveAction -Title 'Comment' - New-AdaptiveAction -Title 'Show Nested, but limited Adaptive Card' -Body { - New-AdaptiveTextBlock -Text 'Publish Adaptive Card schema' -Weight Bolder -Size Medium - New-AdaptiveColumnSet { - New-AdaptiveColumn -Width auto { - New-AdaptiveImage -Url "https://pbs.twimg.com/profile_images/3647943215/d7f12830b3c17a5a9e4afcc370e3a37e_400x400.jpeg" -Size Small -Style person - } - New-AdaptiveColumn -Width stretch { - New-AdaptiveTextBlock -Text "Matt Hidinger" -Weight Bolder -Wrap - New-AdaptiveTextBlock -Text "Created {{DATE(2017-02-14T06:08:39Z, SHORT)}}" -Subtle -Spacing None -Wrap - } - } - New-AdaptiveFactSet { - New-AdaptiveFact -Title 'Board:' -Value 'Adaptive Card' - New-AdaptiveFact -Title 'List:' -Value 'Backlog' - New-AdaptiveFact -Title 'Assigned to:' -Value 'Matt Hidinger' - New-AdaptiveFact -Title 'Due date:' -Value 'Not set' - } - } - } -ErrorAction Stop - $Output | Should -Be $null - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file diff --git a/Tests/Send-CardList.Tests.ps1 b/Tests/Send-CardList.Tests.ps1 deleted file mode 100644 index 0cd68bd..0000000 --- a/Tests/Send-CardList.Tests.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'New-CardList - Should send message properly' { - It 'Should not throw error' { - $Output = New-CardList { - New-CardListItem -Type file -Title 'Report' -SubTitle 'teams > new > design' -TapType openUrl -TapValue "https://contoso.sharepoint.com/teams/new/Shared%20Documents/Report.xlsx" -TapAction editOnline - New-CardListItem -Type resultItem -Title 'Report' -SubTitle 'teams > new > design' -TapType openUrl -TapValue "https://contoso.sharepoint.com/teams/new/Shared%20Documents/Report.xlsx" - New-CardListItem -Type resultItem -Title 'Trello title' -SubTitle 'A Trello subtitle' -TapType openUrl -TapValue "http://trello.com" -Icon "https://cdn2.iconfinder.com/data/icons/social-icons-33/128/Trello-128.png" - New-CardListItem -Type section -Title 'Manager' - New-CardListItem -Type person -Title "John Doe" -SubTitle 'Manager' -TapType imBack -TapValue "JohnDoe@contoso.com" -TapAction whois - New-CardListButton -Type openUrl -Title 'Show' -Value 'https://evotec.xyz' - } -Uri $TeamsID -Title 'Card Title' -ErrorAction Stop - $Output | Should -Be $null - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file diff --git a/Tests/Send-HeroCard.Tests.ps1 b/Tests/Send-HeroCard.Tests.ps1 deleted file mode 100644 index e316f6c..0000000 --- a/Tests/Send-HeroCard.Tests.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'New-HeroCard - Should send message properly' { - It 'Should not throw error' { - $Output = New-HeroCard -Title 'Seattle Center Monorail' -SubTitle 'Seattle Center Monorail' -Text "The Seattle Center Monorail is an elevated train line between Seattle Center (near the Space Needle) and downtown Seattle. It was built for the 1962 World's Fair. Its original two trains, completed in 1961, are still in service." { - New-HeroImage -Url 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Seattle_monorail01_2008-02-25.jpg/1024px-Seattle_monorail01_2008-02-25.jpg' - New-HeroButton -Type openUrl -Title 'Official website' -Value 'https://www.seattlemonorail.com' - New-HeroButton -Type openUrl -Title 'Wikipeda page' -Value 'https://www.seattlemonorail.com' - New-HeroButton -Type imBack -Title 'Evotec page' -Value 'https://www.evotec.xyz' - } -Uri $TeamsID -ErrorAction Stop - $Output | Should -Be $null - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file diff --git a/Tests/Send-TeamsMessage.Tests.ps1 b/Tests/Send-TeamsMessage.Tests.ps1 deleted file mode 100644 index df575b8..0000000 --- a/Tests/Send-TeamsMessage.Tests.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'Send-TeamsMessage - Should send messages properly' { - It 'Given 1 button, 3 facts, 1 section should not throw' { - $Button1 = New-TeamsButton -Name 'Visit English Evotec Website' -Link "https://evotec.xyz" - - $Fact1 = New-TeamsFact -Name 'PS Version' -Value "**$($PSVersionTable.PSVersion)**" - $Fact2 = New-TeamsFact -Name 'PS Edition' -Value "**$($PSVersionTable.PSEdition)**" - $Fact3 = New-TeamsFact -Name 'OS' -Value "**$($PSVersionTable.OS)**" - - $CurrentDate = Get-Date - - $Section = New-TeamsSection ` - -ActivityTitle "**PSTeams**" ` - -ActivitySubtitle "@PSTeams - $CurrentDate" ` - -ActivityImage Add ` - -ActivityText "This message proves PSTeams Pester test passed properly." ` - -Buttons $Button1 ` - -ActivityDetails $Fact1, $Fact2, $Fact3 - - Send-TeamsMessage ` - -Uri $TeamsID ` - -MessageTitle 'PSTeams - Pester Test' ` - -MessageText "This text won't show up" ` - -Color DodgerBlue ` - -Sections $Section -ErrorAction Stop - } -TestCases @{ TeamsID = $TeamsID } - It 'Given 3 facts, 1 section should not throw' { - $Fact1 = New-TeamsFact -Name 'PS Version' -Value "**$($PSVersionTable.PSVersion)**" - $Fact2 = New-TeamsFact -Name 'PS Edition' -Value "**$($PSVersionTable.PSEdition)**" - $Fact3 = New-TeamsFact -Name 'OS' -Value "**$($PSVersionTable.OS)**" - - $CurrentDate = Get-Date - - $Section = New-TeamsSection ` - -ActivityTitle "**PSTeams**" ` - -ActivitySubtitle "@PSTeams - $CurrentDate" ` - -ActivityImage Add ` - -ActivityText "This message proves PSTeams Pester test passed properly." ` - -ActivityDetails $Fact1, $Fact2, $Fact3 - - Send-TeamsMessage ` - -Uri $TeamsID ` - -MessageTitle 'PSTeams - Pester Test' ` - -MessageText "This text won't show up" ` - -Color DodgerBlue ` - -Sections $Section -ErrorAction Stop - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file diff --git a/Tests/Send-TeamsNew.Tests.ps1 b/Tests/Send-TeamsNew.Tests.ps1 deleted file mode 100644 index 5cd8df8..0000000 --- a/Tests/Send-TeamsNew.Tests.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'Send-TeamsMessage - Should send messages properly with new syntax' { - It 'Given 3 facts, 1 section should not throw' { - Send-TeamsMessage -Uri $TeamsID -MessageTitle 'PSTeams - Pester Test' -MessageText "This text will show up" -Color DodgerBlue { - New-TeamsSection { - New-TeamsActivityTitle -Title "**PSTeams**" - New-TeamsActivitySubtitle -Subtitle "@PSTeams - $CurrentDate" - New-TeamsActivityImage -Image Add - New-TeamsActivityText -Text "This message proves PSTeams Pester test passed properly." - New-TeamsFact -Name 'PS Version' -Value "**$($PSVersionTable.PSVersion)**" - New-TeamsFact -Name 'PS Edition' -Value "**$($PSVersionTable.PSEdition)**" - New-TeamsFact -Name 'OS' -Value "**$($PSVersionTable.OS)**" - New-TeamsButton -Name 'Visit English Evotec Website' -Link "https://evotec.xyz" - } - } -ErrorAction Stop - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file diff --git a/Tests/Send-Thumbnail.Tests.ps1 b/Tests/Send-Thumbnail.Tests.ps1 deleted file mode 100644 index 44dd43f..0000000 --- a/Tests/Send-Thumbnail.Tests.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -param( - $TeamsID = $Env:TEAMSPESTERID -) - -Describe 'New-ThumbnailCard - Should send message properly' { - It 'Should not throw error' { - $Output = New-ThumbnailCard -Title 'Bender' -SubTitle "tale of a robot who dared to love" -Text "Bender Bending Rodríguez is a main character in the animated television series Futurama. He was created by series creators Matt Groening and David X. Cohen, and is voiced by John DiMaggio" { - New-ThumbnailImage -Url 'https://upload.wikimedia.org/wikipedia/en/a/a6/Bender_Rodriguez.png' -AltText "Bender Rodríguez" - New-ThumbnailButton -Type imBack -Title 'Thumbs Up' -Value 'I like it' -Image "http://moopz.com/assets_c/2012/06/emoji-thumbs-up-150-thumb-autox125-140616.jpg" - New-ThumbnailButton -Type openUrl -Title 'Thumbs Down' -Value 'https://evotec.xyz' - New-ThumbnailButton -Type openUrl -Title 'I feel luck' -Value 'https://www.bing.com/images/search?q=bender&qpvt=bender&qpvt=bender&qpvt=bender&FORM=IGRE' - } -Uri $TeamsID -ErrorAction Stop - $Output | Should -Be $null - } -TestCases @{ TeamsID = $TeamsID } -} \ No newline at end of file From 2719ac5c0dd9065496d3bd6d1edddd4e78435360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 22 Apr 2026 23:20:33 +0200 Subject: [PATCH 02/10] Fix CI refresh and parity tests --- Module/PSTeams/PSTeams.psd1 | 2 +- Module/PSTeams/PSTeams.psm1 | 4 +- Module/Tests/Baselines/LegacyAliases.json | 27 +++++++++++ Module/Tests/Baselines/LegacyCommands.json | 45 +++++++++++++++++++ Module/Tests/Import-Module.Tests.ps1 | 16 +++---- .../Tests/Legacy.Adaptive.Compose.Tests.ps1 | 1 + TeamsX.PowerShell/CmdletNewAdaptiveAction.cs | 3 +- 7 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 Module/Tests/Baselines/LegacyAliases.json create mode 100644 Module/Tests/Baselines/LegacyCommands.json diff --git a/Module/PSTeams/PSTeams.psd1 b/Module/PSTeams/PSTeams.psd1 index c3fc108..760527f 100644 --- a/Module/PSTeams/PSTeams.psd1 +++ b/Module/PSTeams/PSTeams.psd1 @@ -9,7 +9,7 @@ PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @() - CmdletsToExport = @('ConvertTo-TeamsFact', 'ConvertTo-TeamsJson', 'ConvertTo-TeamsSection', 'New-AdaptiveAction', 'New-AdaptiveActionSet', 'New-AdaptiveCard', 'New-AdaptiveColumn', 'New-AdaptiveColumnSet', 'New-AdaptiveContainer', 'New-AdaptiveFact', 'New-AdaptiveFactSet', 'New-AdaptiveImage', 'New-AdaptiveImageSet', 'New-AdaptiveLineBreak', 'New-AdaptiveMedia', 'New-AdaptiveMediaSource', 'New-AdaptiveMention', 'New-AdaptiveRichTextBlock', 'New-AdaptiveTable', 'New-AdaptiveTextBlock', 'New-CardList', 'New-CardListButton', 'New-CardListItem', 'New-HeroCard', 'New-TeamsAdaptiveActionSet', 'New-TeamsAdaptiveCard', 'New-TeamsAdaptiveColumn', 'New-TeamsAdaptiveColumnSet', 'New-TeamsAdaptiveContainer', 'New-TeamsAdaptiveFact', 'New-TeamsAdaptiveFactSet', 'New-TeamsAdaptiveImage', 'New-TeamsAdaptiveImageSet', 'New-TeamsAdaptiveMedia', 'New-TeamsAdaptiveMediaSource', 'New-TeamsAdaptiveMention', 'New-TeamsAdaptiveOpenUrlAction', 'New-TeamsAdaptiveRichTextBlock', 'New-TeamsAdaptiveShowCardAction', 'New-TeamsAdaptiveSubmitAction', 'New-TeamsAdaptiveTextBlock', 'New-TeamsAdaptiveTextRun', 'New-TeamsAdaptiveToggleVisibilityAction', 'New-TeamsActivityImage', 'New-TeamsActivitySubtitle', 'New-TeamsActivityText', 'New-TeamsActivityTitle', 'New-TeamsBigImage', 'New-TeamsButton', 'New-TeamsCardImage', 'New-TeamsFact', 'New-TeamsGraphTarget', 'New-TeamsHeroCard', 'New-TeamsImage', 'New-TeamsList', 'New-TeamsListCard', 'New-TeamsListItem', 'New-TeamsMessage', 'New-TeamsSection', 'New-TeamsThumbnailCard', 'New-TeamsWebhookTarget', 'New-ThumbnailCard', 'Send-TeamsMessage', 'Send-TeamsMessageBody') + CmdletsToExport = @('ConvertTo-TeamsFact', 'ConvertTo-TeamsJson', 'ConvertTo-TeamsSection', 'New-AdaptiveAction', 'New-AdaptiveActionSet', 'New-AdaptiveCard', 'New-AdaptiveColumn', 'New-AdaptiveColumnSet', 'New-AdaptiveContainer', 'New-AdaptiveFact', 'New-AdaptiveFactSet', 'New-AdaptiveImage', 'New-AdaptiveImageSet', 'New-AdaptiveLineBreak', 'New-AdaptiveMedia', 'New-AdaptiveMediaSource', 'New-AdaptiveMention', 'New-AdaptiveRichTextBlock', 'New-AdaptiveTable', 'New-AdaptiveTextBlock', 'New-CardList', 'New-CardListButton', 'New-CardListItem', 'New-HeroCard', 'New-TeamsAdaptiveActionSet', 'New-TeamsAdaptiveCard', 'New-TeamsAdaptiveColumn', 'New-TeamsAdaptiveColumnSet', 'New-TeamsAdaptiveContainer', 'New-TeamsAdaptiveFact', 'New-TeamsAdaptiveFactSet', 'New-TeamsAdaptiveImage', 'New-TeamsAdaptiveImageSet', 'New-TeamsAdaptiveMedia', 'New-TeamsAdaptiveMediaSource', 'New-TeamsAdaptiveMention', 'New-TeamsAdaptiveOpenUrlAction', 'New-TeamsAdaptiveRichTextBlock', 'New-TeamsAdaptiveShowCardAction', 'New-TeamsAdaptiveSubmitAction', 'New-TeamsAdaptiveTextBlock', 'New-TeamsAdaptiveTextRun', 'New-TeamsAdaptiveToggleVisibilityAction', 'New-TeamsActivityImage', 'New-TeamsActivitySubtitle', 'New-TeamsActivityText', 'New-TeamsActivityTitle', 'New-TeamsBigImage', 'New-TeamsButton', 'New-TeamsCardImage', 'New-TeamsFact', 'New-TeamsGraphTarget', 'New-TeamsHeroCard', 'New-TeamsImage', 'New-TeamsList', 'New-TeamsListCard', 'New-TeamsListItem', 'New-TeamsMessage', 'New-TeamsSection', 'New-TeamsThumbnailCard', 'New-TeamsWebhookTarget', 'New-ThumbnailCard', 'Send-TeamsMessage', 'Send-TeamsMessageBody') AliasesToExport = @('New-HeroImage', 'New-ThumbnailImage', 'New-AdaptiveImageGallery', 'New-HeroButton', 'New-ThumbnailButton', 'ActivityImageLink', 'TeamsActivityImageLink', 'New-TeamsActivityImageLink', 'ActivityImage', 'TeamsActivityImage', 'ActivitySubtitle', 'TeamsActivitySubtitle', 'ActivityText', 'TeamsActivityText', 'ActivityTitle', 'TeamsActivityTitle', 'TeamsBigImage', 'TeamsButton', 'TeamsFact', 'TeamsImage', 'TeamsList', 'TeamsListItem', 'TeamsSection', 'TeamsMessage', 'TeamsMessageBody') PrivateData = @{ PSData = @{ diff --git a/Module/PSTeams/PSTeams.psm1 b/Module/PSTeams/PSTeams.psm1 index 26b833c..c7b74bc 100644 --- a/Module/PSTeams/PSTeams.psm1 +++ b/Module/PSTeams/PSTeams.psm1 @@ -4,8 +4,10 @@ $preferredFolders = if ($PSEdition -eq 'Core') { $runtimeMajor = [System.Environment]::Version.Major if ($runtimeMajor -ge 10) { @('net10.0', 'net8.0', 'netstandard2.0') - } else { + } elseif ($runtimeMajor -ge 8) { @('net8.0', 'netstandard2.0') + } else { + @('netstandard2.0') } } else { @('net472', 'netstandard2.0') diff --git a/Module/Tests/Baselines/LegacyAliases.json b/Module/Tests/Baselines/LegacyAliases.json new file mode 100644 index 0000000..bfe9bde --- /dev/null +++ b/Module/Tests/Baselines/LegacyAliases.json @@ -0,0 +1,27 @@ +[ + "ActivityImage=>New-TeamsActivityImage", + "ActivityImageLink=>New-TeamsActivityImage", + "ActivitySubtitle=>New-TeamsActivitySubtitle", + "ActivityText=>New-TeamsActivityText", + "ActivityTitle=>New-TeamsActivityTitle", + "New-AdaptiveImageGallery=>New-AdaptiveImageSet", + "New-HeroButton=>New-CardListButton", + "New-HeroImage=>New-AdaptiveImage", + "New-TeamsActivityImageLink=>New-TeamsActivityImage", + "New-ThumbnailButton=>New-CardListButton", + "New-ThumbnailImage=>New-AdaptiveImage", + "TeamsActivityImage=>New-TeamsActivityImage", + "TeamsActivityImageLink=>New-TeamsActivityImage", + "TeamsActivitySubtitle=>New-TeamsActivitySubtitle", + "TeamsActivityText=>New-TeamsActivityText", + "TeamsActivityTitle=>New-TeamsActivityTitle", + "TeamsBigImage=>New-TeamsBigImage", + "TeamsButton=>New-TeamsButton", + "TeamsFact=>New-TeamsFact", + "TeamsImage=>New-TeamsImage", + "TeamsList=>New-TeamsList", + "TeamsListItem=>New-TeamsListItem", + "TeamsMessage=>Send-TeamsMessage", + "TeamsMessageBody=>Send-TeamsMessageBody", + "TeamsSection=>New-TeamsSection" +] diff --git a/Module/Tests/Baselines/LegacyCommands.json b/Module/Tests/Baselines/LegacyCommands.json new file mode 100644 index 0000000..30988ec --- /dev/null +++ b/Module/Tests/Baselines/LegacyCommands.json @@ -0,0 +1,45 @@ +[ + "ConvertTo-TeamsFact", + "ConvertTo-TeamsSection", + "New-AdaptiveAction", + "New-AdaptiveActionSet", + "New-AdaptiveCard", + "New-AdaptiveColumn", + "New-AdaptiveColumnSet", + "New-AdaptiveContainer", + "New-AdaptiveFact", + "New-AdaptiveFactSet", + "New-AdaptiveImage", + "New-AdaptiveImageGallery", + "New-AdaptiveImageSet", + "New-AdaptiveLineBreak", + "New-AdaptiveMedia", + "New-AdaptiveMediaSource", + "New-AdaptiveMention", + "New-AdaptiveRichTextBlock", + "New-AdaptiveTable", + "New-AdaptiveTextBlock", + "New-CardList", + "New-CardListButton", + "New-CardListItem", + "New-HeroButton", + "New-HeroCard", + "New-HeroImage", + "New-TeamsActivityImage", + "New-TeamsActivityImageLink", + "New-TeamsActivitySubtitle", + "New-TeamsActivityText", + "New-TeamsActivityTitle", + "New-TeamsBigImage", + "New-TeamsButton", + "New-TeamsFact", + "New-TeamsImage", + "New-TeamsList", + "New-TeamsListItem", + "New-TeamsSection", + "New-ThumbnailButton", + "New-ThumbnailCard", + "New-ThumbnailImage", + "Send-TeamsMessage", + "Send-TeamsMessageBody" +] diff --git a/Module/Tests/Import-Module.Tests.ps1 b/Module/Tests/Import-Module.Tests.ps1 index f1a3723..bca5a4e 100644 --- a/Module/Tests/Import-Module.Tests.ps1 +++ b/Module/Tests/Import-Module.Tests.ps1 @@ -1,4 +1,8 @@ Describe 'PSTeams module migration shell' { + BeforeAll { + $script:baselinePath = Join-Path -Path $PSScriptRoot -ChildPath 'Baselines' + } + BeforeEach { Get-Module PSTeams, TeamsX.PowerShell | Remove-Module -Force -ErrorAction SilentlyContinue } @@ -119,17 +123,13 @@ Describe 'PSTeams module migration shell' { } It 'preserves every legacy command name on main' { - $legacyScript = @' -Import-Module 'C:\Support\GitHub\PSTeams\PSTeams.psd1' -Force -Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | ConvertTo-Json -'@ $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") $currentScript = @' Import-Module '{CURRENT_PATH}' -Force Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | ConvertTo-Json '@.Replace('{CURRENT_PATH}', $currentPath) - $legacyNames = @(pwsh -NoProfile -Command $legacyScript | ConvertFrom-Json) + $legacyNames = @(Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyCommands.json') -Raw | ConvertFrom-Json) $currentNames = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) $missing = @($legacyNames | Where-Object { $_ -notin $currentNames } | Sort-Object) @@ -137,17 +137,13 @@ Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | } It 'preserves every legacy alias target on main' { - $legacyScript = @' -Import-Module 'C:\Support\GitHub\PSTeams\PSTeams.psd1' -Force -Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object | ConvertTo-Json -'@ $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") $currentScript = @' Import-Module '{CURRENT_PATH}' -Force Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object | ConvertTo-Json '@.Replace('{CURRENT_PATH}', $currentPath) - $legacyAliases = @(pwsh -NoProfile -Command $legacyScript | ConvertFrom-Json) + $legacyAliases = @(Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyAliases.json') -Raw | ConvertFrom-Json) $currentAliases = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) $missing = @($legacyAliases | Where-Object { $_ -notin $currentAliases } | Sort-Object) diff --git a/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 b/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 index 30ca32d..5e0f499 100644 --- a/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 +++ b/Module/Tests/Legacy.Adaptive.Compose.Tests.ps1 @@ -59,6 +59,7 @@ Describe 'Legacy adaptive leaf migration cmdlets' { $mention.Text | Should -Be 'Ops Team' $submitAction.GetType().Name | Should -Be 'TeamsAdaptiveSubmitAction' $openUrlAction.GetType().Name | Should -Be 'TeamsAdaptiveOpenUrlAction' + (New-AdaptiveAction -Title 'Open later' -Type Action.OpenUrl).GetType().Name | Should -Be 'TeamsAdaptiveOpenUrlAction' $showCardAction.GetType().Name | Should -Be 'TeamsAdaptiveShowCardAction' $showCardAction.Card.type | Should -Be 'AdaptiveCard' $container.GetType().Name | Should -Be 'TeamsAdaptiveContainer' diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs b/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs index c2f0583..55f22ed 100644 --- a/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs +++ b/TeamsX.PowerShell/CmdletNewAdaptiveAction.cs @@ -26,7 +26,8 @@ public sealed class CmdletNewAdaptiveAction : PSCmdlet { public string? Title { get; set; } protected override void ProcessRecord() { - if (!string.IsNullOrWhiteSpace(ActionUrl)) { + if (!string.IsNullOrWhiteSpace(ActionUrl) || + string.Equals(Type, "Action.OpenUrl", StringComparison.OrdinalIgnoreCase)) { WriteObject(new TeamsAdaptiveOpenUrlAction { Title = Title ?? string.Empty, Url = ActionUrl ?? string.Empty From a55419fb1d3eb849feb0cc7a5d5709ef18d1a4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 22 Apr 2026 23:26:45 +0200 Subject: [PATCH 03/10] Align PSTeams module shell with DnsClientX --- Module/PSTeams/PSTeams.psm1 | 195 +++++++++++++----- .../PSTeams/Private/Register-TeamsAliases.ps1 | 31 +++ 2 files changed, 176 insertions(+), 50 deletions(-) create mode 100644 Module/PSTeams/Private/Register-TeamsAliases.ps1 diff --git a/Module/PSTeams/PSTeams.psm1 b/Module/PSTeams/PSTeams.psm1 index c7b74bc..a300977 100644 --- a/Module/PSTeams/PSTeams.psm1 +++ b/Module/PSTeams/PSTeams.psm1 @@ -1,64 +1,159 @@ -$binaryName = 'TeamsX.PowerShell.dll' +# Get public and private function definition files. +$Public = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -ErrorAction SilentlyContinue -Recurse -File) +$Private = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -ErrorAction SilentlyContinue -Recurse -File) +$Classes = @(Get-ChildItem -Path $PSScriptRoot\Classes\*.ps1 -ErrorAction SilentlyContinue -Recurse -File) +$Enums = @(Get-ChildItem -Path $PSScriptRoot\Enums\*.ps1 -ErrorAction SilentlyContinue -Recurse -File) + +$binaryModuleName = 'TeamsX.PowerShell.dll' +$binaryModules = @( + $binaryModuleName +) + +# Keep the source-tree module usable during development. Production packaging is handled by Build-Module.ps1. +$development = $true $developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' -$preferredFolders = if ($PSEdition -eq 'Core') { - $runtimeMajor = [System.Environment]::Version.Major - if ($runtimeMajor -ge 10) { - @('net10.0', 'net8.0', 'netstandard2.0') - } elseif ($runtimeMajor -ge 8) { - @('net8.0', 'netstandard2.0') - } else { - @('netstandard2.0') - } +$developmentFolderDefault = 'net472' +$preferredDevelopmentCoreFolders = if ([System.Environment]::Version.Major -ge 10) { + @('net10.0', 'net8.0', 'netstandard2.0') +} elseif ([System.Environment]::Version.Major -ge 8) { + @('net8.0', 'netstandard2.0') } else { - @('net472', 'netstandard2.0') + @('netstandard2.0') } -$modulePath = $null -foreach ($folder in $preferredFolders) { - $candidate = Join-Path -Path $developmentPath -ChildPath "$folder\$binaryName" - if (Test-Path -LiteralPath $candidate) { - $modulePath = $candidate +$developmentFolderCore = foreach ($folder in $preferredDevelopmentCoreFolders) { + if (Test-Path -LiteralPath (Join-Path -Path $developmentPath -ChildPath "$folder\$binaryModuleName")) { + $folder break } } - -if (-not $modulePath) { - $libFolder = if ($PSEdition -eq 'Core') { 'Core' } else { 'Default' } - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath "Lib\$libFolder\$binaryName" +if (-not $developmentFolderCore) { + $developmentFolderCore = $preferredDevelopmentCoreFolders[0] } -Import-Module -Name $modulePath -Force -ErrorAction Stop +# Lets find which libraries we need to load when running from a built module layout. +$default = $false +$core = $false +$standard = $false +$assemblyFolders = @(Get-ChildItem -Path $PSScriptRoot\Lib -Directory -ErrorAction SilentlyContinue) +foreach ($folder in $assemblyFolders.Name) { + if ($folder -eq 'Default') { + $default = $true + } elseif ($folder -eq 'Core') { + $core = $true + } elseif ($folder -eq 'Standard') { + $standard = $true + } +} -$binaryAliases = @{ - ActivityImage = 'New-TeamsActivityImage' - ActivityImageLink = 'New-TeamsActivityImage' - ActivitySubtitle = 'New-TeamsActivitySubtitle' - ActivityText = 'New-TeamsActivityText' - ActivityTitle = 'New-TeamsActivityTitle' - 'New-AdaptiveImageGallery' = 'New-AdaptiveImageSet' - 'New-HeroButton' = 'New-CardListButton' - 'New-HeroImage' = 'New-AdaptiveImage' - 'New-TeamsActivityImageLink' = 'New-TeamsActivityImage' - 'New-ThumbnailButton' = 'New-CardListButton' - 'New-ThumbnailImage' = 'New-AdaptiveImage' - TeamsActivityImage = 'New-TeamsActivityImage' - TeamsActivityImageLink = 'New-TeamsActivityImage' - TeamsActivitySubtitle = 'New-TeamsActivitySubtitle' - TeamsActivityText = 'New-TeamsActivityText' - TeamsActivityTitle = 'New-TeamsActivityTitle' - TeamsBigImage = 'New-TeamsBigImage' - TeamsButton = 'New-TeamsButton' - TeamsFact = 'New-TeamsFact' - TeamsImage = 'New-TeamsImage' - TeamsList = 'New-TeamsList' - TeamsListItem = 'New-TeamsListItem' - TeamsSection = 'New-TeamsSection' - TeamsMessage = 'Send-TeamsMessage' - TeamsMessageBody = 'Send-TeamsMessageBody' +if ($standard -and $core -and $default) { + $framework = 'Standard' + $frameworkNet = 'Default' +} elseif ($standard -and $core) { + $framework = 'Standard' + $frameworkNet = 'Standard' +} elseif ($core -and $default) { + $framework = 'Core' + $frameworkNet = 'Default' +} elseif ($standard -and $default) { + $framework = 'Standard' + $frameworkNet = 'Default' +} elseif ($standard) { + $framework = 'Standard' + $frameworkNet = 'Standard' +} elseif ($core) { + $framework = 'Core' + $frameworkNet = '' +} elseif ($default) { + $framework = '' + $frameworkNet = 'Default' +} else { + $framework = '' + $frameworkNet = '' } -foreach ($alias in $binaryAliases.GetEnumerator()) { - Set-Alias -Name $alias.Key -Value $alias.Value -Scope Local +$binaryDev = @( + foreach ($binaryModule in $binaryModules) { + if ($PSEdition -eq 'Core') { + $path = Resolve-Path (Join-Path -Path $developmentPath -ChildPath "$developmentFolderCore\$binaryModule") -ErrorAction SilentlyContinue + } else { + $path = Resolve-Path (Join-Path -Path $developmentPath -ChildPath "$developmentFolderDefault\$binaryModule") -ErrorAction SilentlyContinue + } + + if ($path) { + $path + } + } +) + +$assemblies = @( + if ($framework -and $PSEdition -eq 'Core') { + Get-ChildItem -Path $PSScriptRoot\Lib\$framework\*.dll -ErrorAction SilentlyContinue -Recurse -File + } + if ($frameworkNet -and $PSEdition -ne 'Core') { + Get-ChildItem -Path $PSScriptRoot\Lib\$frameworkNet\*.dll -ErrorAction SilentlyContinue -Recurse -File + } +) + +$foundErrors = @( + if ($development -and $binaryDev.Count -gt 0) { + foreach ($binaryModule in $binaryDev) { + try { + Import-Module -Name $binaryModule -Force -ErrorAction Stop + } catch { + Write-Warning "Failed to import module $($binaryModule): $($_.Exception.Message)" + $true + } + } + } else { + foreach ($binaryModule in $binaryModules) { + try { + if ($framework -and $PSEdition -eq 'Core') { + Import-Module -Name "$PSScriptRoot\Lib\$framework\$binaryModule" -Force -ErrorAction Stop + } + if ($frameworkNet -and $PSEdition -ne 'Core') { + Import-Module -Name "$PSScriptRoot\Lib\$frameworkNet\$binaryModule" -Force -ErrorAction Stop + } + } catch { + Write-Warning "Failed to import module $($binaryModule): $($_.Exception.Message)" + $true + } + } + } + + foreach ($import in @($assemblies)) { + try { + Add-Type -Path $import.FullName -ErrorAction Stop + } catch [System.Reflection.ReflectionTypeLoadException] { + Write-Warning "Processing $($import.Name) exception: $($_.Exception.Message)" + foreach ($loaderException in ($_.Exception.LoaderExceptions | Sort-Object -Unique)) { + Write-Warning "Processing $($import.Name) LoaderExceptions: $($loaderException.Message)" + } + $true + } catch { + Write-Warning "Processing $($import.Name) exception: $($_.Exception.Message)" + foreach ($loaderException in ($_.Exception.LoaderExceptions | Sort-Object -Unique)) { + Write-Warning "Processing $($import.Name) LoaderExceptions: $($loaderException.Message)" + } + $true + } + } + + # Dot source the files. + foreach ($import in @($Classes + $Enums + $Private + $Public)) { + try { + . $import.FullName + } catch { + Write-Error -Message "Failed to import functions from $($import.FullName): $_" + $true + } + } +) + +if ($foundErrors.Count -gt 0) { + $moduleName = (Get-ChildItem $PSScriptRoot\*.psd1).BaseName + Write-Warning "Importing module $moduleName failed. Fix errors before continuing." + break } -Export-ModuleMember -Alias * -Cmdlet * +Export-ModuleMember -Function * -Alias * -Cmdlet * diff --git a/Module/PSTeams/Private/Register-TeamsAliases.ps1 b/Module/PSTeams/Private/Register-TeamsAliases.ps1 new file mode 100644 index 0000000..06dee39 --- /dev/null +++ b/Module/PSTeams/Private/Register-TeamsAliases.ps1 @@ -0,0 +1,31 @@ +$binaryAliases = [ordered] @{ + ActivityImage = 'New-TeamsActivityImage' + ActivityImageLink = 'New-TeamsActivityImage' + ActivitySubtitle = 'New-TeamsActivitySubtitle' + ActivityText = 'New-TeamsActivityText' + ActivityTitle = 'New-TeamsActivityTitle' + 'New-AdaptiveImageGallery' = 'New-AdaptiveImageSet' + 'New-HeroButton' = 'New-CardListButton' + 'New-HeroImage' = 'New-AdaptiveImage' + 'New-TeamsActivityImageLink' = 'New-TeamsActivityImage' + 'New-ThumbnailButton' = 'New-CardListButton' + 'New-ThumbnailImage' = 'New-AdaptiveImage' + TeamsActivityImage = 'New-TeamsActivityImage' + TeamsActivityImageLink = 'New-TeamsActivityImage' + TeamsActivitySubtitle = 'New-TeamsActivitySubtitle' + TeamsActivityText = 'New-TeamsActivityText' + TeamsActivityTitle = 'New-TeamsActivityTitle' + TeamsBigImage = 'New-TeamsBigImage' + TeamsButton = 'New-TeamsButton' + TeamsFact = 'New-TeamsFact' + TeamsImage = 'New-TeamsImage' + TeamsList = 'New-TeamsList' + TeamsListItem = 'New-TeamsListItem' + TeamsSection = 'New-TeamsSection' + TeamsMessage = 'Send-TeamsMessage' + TeamsMessageBody = 'Send-TeamsMessageBody' +} + +foreach ($alias in $binaryAliases.GetEnumerator()) { + Set-Alias -Name $alias.Key -Value $alias.Value -Scope Local +} From 1614410e0eb3b06d4ebdd3105d5ac3b70433cd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 22 Apr 2026 23:33:46 +0200 Subject: [PATCH 04/10] Prefer net8 in development module loader --- Docs/PowerShell-Surface.md | 2 +- Module/PSTeams/PSTeams.psm1 | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Docs/PowerShell-Surface.md b/Docs/PowerShell-Surface.md index 86dd1c9..aed6d1c 100644 --- a/Docs/PowerShell-Surface.md +++ b/Docs/PowerShell-Surface.md @@ -61,7 +61,7 @@ - `FunctionsToExport` in `Module\PSTeams\PSTeams.psd1` is now empty. - The whole `New-Adaptive*` surface is binary-backed on `main`. -- `Module\PSTeams\PSTeams.psm1` now prefers the highest compatible local PowerShell Core build, including `net10.0` when the current host runtime can load it. +- `Module\PSTeams\PSTeams.psm1` now follows the `DnsClientX`-style development loader and prefers `net8.0` for PowerShell 7.x, with `net10.0` only as a fallback development build when present. - Remaining work is now quality and parity polish: warnings cleanup, docs/examples refresh, and feature expansion on the typed cmdlet surface. - `TeamsX` now includes a Graph sender starter for channel and chat posts, exposed through `New-TeamsGraphTarget`. diff --git a/Module/PSTeams/PSTeams.psm1 b/Module/PSTeams/PSTeams.psm1 index a300977..723526c 100644 --- a/Module/PSTeams/PSTeams.psm1 +++ b/Module/PSTeams/PSTeams.psm1 @@ -9,17 +9,16 @@ $binaryModules = @( $binaryModuleName ) -# Keep the source-tree module usable during development. Production packaging is handled by Build-Module.ps1. -$development = $true -$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' -$developmentFolderDefault = 'net472' -$preferredDevelopmentCoreFolders = if ([System.Environment]::Version.Major -ge 10) { - @('net10.0', 'net8.0', 'netstandard2.0') -} elseif ([System.Environment]::Version.Major -ge 8) { - @('net8.0', 'netstandard2.0') -} else { - @('netstandard2.0') -} +# Keep the source-tree module usable during development. Prefer the PowerShell 7.x support build. +# Production packaging is handled by Build-Module.ps1. +$development = $true +$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' +$developmentFolderDefault = 'net472' +$preferredDevelopmentCoreFolders = if ([System.Environment]::Version.Major -ge 8) { + @('net8.0', 'net10.0', 'netstandard2.0') +} else { + @('net8.0', 'netstandard2.0') +} $developmentFolderCore = foreach ($folder in $preferredDevelopmentCoreFolders) { if (Test-Path -LiteralPath (Join-Path -Path $developmentPath -ChildPath "$folder\$binaryModuleName")) { From f6aadbff7138cfe89611a3bdef733692d44ee054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 07:36:00 +0200 Subject: [PATCH 05/10] Improve README introduction and capabilities --- README.md | 88 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 4cd2d03..65a2960 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,65 @@ -

    - - - - -

    +# PSTeams - Microsoft Teams Notifications for PowerShell and .NET -

    - - - - -

    +PSTeams is available as a PowerShell module from PowerShell Gallery and is powered by a reusable C# library named `TeamsX`. -

    - - - -

    +## PowerShell Module -# PSTeams - PowerShell Module +[![powershell gallery version](https://img.shields.io/powershellgallery/v/PSTeams.svg)](https://www.powershellgallery.com/packages/PSTeams) +[![powershell gallery preview](https://img.shields.io/powershellgallery/v/PSTeams.svg?label=powershell%20gallery%20preview&colorB=yellow&include_prereleases)](https://www.powershellgallery.com/packages/PSTeams) +[![powershell gallery platforms](https://img.shields.io/powershellgallery/p/PSTeams.svg)](https://www.powershellgallery.com/packages/PSTeams) +[![powershell gallery downloads](https://img.shields.io/powershellgallery/dt/PSTeams.svg)](https://www.powershellgallery.com/packages/PSTeams) -## Main Branch Note +## Project Information -The `main` branch keeps shipping `PSTeams`, but the implementation model has changed. +[![Test .NET](https://github.com/EvotecIT/PSTeams/actions/workflows/test-dotnet.yml/badge.svg)](https://github.com/EvotecIT/PSTeams/actions/workflows/test-dotnet.yml) +[![Test PowerShell](https://github.com/EvotecIT/PSTeams/actions/workflows/test-powershell.yml/badge.svg)](https://github.com/EvotecIT/PSTeams/actions/workflows/test-powershell.yml) +[![top language](https://img.shields.io/github/languages/top/evotecit/PSTeams.svg)](https://github.com/EvotecIT/PSTeams) +[![code size](https://img.shields.io/github/languages/code-size/evotecit/PSTeams.svg)](https://github.com/EvotecIT/PSTeams) +[![license](https://img.shields.io/github/license/EvotecIT/PSTeams.svg)](https://github.com/EvotecIT/PSTeams) -`TeamsX` is now the reusable .NET library, `TeamsX.PowerShell` provides thin C# cmdlets over that library, and `Module\PSTeams` is the real shipping module shell. -Migration is 1:1: existing PowerShell functions stay only until equivalent cmdlets are ready, and then those script implementations can be removed. +## Author and Social -[PSTeams](https://evotec.xyz/hub/scripts/psteams-powershell-module/) is a **PowerShell Module** working on **Windows** / **Linux** and **Mac**. -It allows sending notifications to _Microsoft Teams_ via **WebHook Notifications**. It's pretty flexible and provides a bunch of options. -Initially it only supported one sort of `Office 365 Connector Card` but since version `2.X.X` it supports `Adaptive Cards`, `Hero Cards`, `List Cards` and `Thumbnail Cards`. -All those new cards have their own cmdlets and the old version of creating Teams Cards stays as is for compatibility reasons. -The most fun you will get from playing with `Adaptive Cards`, but rest has their use case. +[![Twitter follow](https://img.shields.io/twitter/follow/PrzemyslawKlys.svg?label=Twitter%20%40PrzemyslawKlys&style=social)](https://twitter.com/PrzemyslawKlys) +[![Blog](https://img.shields.io/badge/Blog-evotec.xyz-2A6496.svg)](https://evotec.xyz/hub) +[![LinkedIn](https://img.shields.io/badge/LinkedIn-pklys-0077B5.svg?logo=LinkedIn)](https://www.linkedin.com/in/pklys) + +## What it's all about + +[PSTeams](https://evotec.xyz/hub/scripts/psteams-powershell-module/) helps you send rich notifications to Microsoft Teams from PowerShell on Windows, Linux, and macOS. It can build and send classic Office 365 connector cards, Adaptive Cards, Hero Cards, List Cards, and Thumbnail Cards. + +PSTeams uses a cleaner architecture: + +- `TeamsX` is the reusable C# library for composing and delivering Microsoft Teams messages. +- `TeamsX.PowerShell` exposes thin binary PowerShell cmdlets over that library. +- `PSTeams` remains the PowerShell module users install and import. + +## Capabilities + +- Send Microsoft Teams notifications through incoming webhooks and workflow webhooks. +- Compose classic connector-card messages with sections, facts, images, buttons, and activity fields. +- Compose Adaptive Cards with containers, columns, tables, images, media, mentions, rich text, actions, and fallback text. +- Compose Hero, Thumbnail, and List cards with typed cmdlets. +- Convert message objects to JSON before sending, which is useful for testing, logging, and CI validation. +- Use a starter Microsoft Graph delivery path for Teams chats and channels without adding a large Graph SDK dependency. +- Keep the PowerShell module surface familiar while moving implementation into reusable C# cmdlets. + +## Supported .NET and PowerShell Versions + +### TeamsX Library + +- .NET 8.0 and .NET 10.0 for modern cross-platform use +- .NET Standard 2.0 for compatibility +- .NET Framework 4.7.2 for Windows PowerShell 5.1 scenarios + +### PowerShell Module + +- PowerShell 7.x uses the .NET 8.0 binary build by default during development. +- Windows PowerShell 5.1 uses the .NET Framework 4.7.2 binary build during development. +- Packaged module builds are produced by `Module\PSTeams\Build\Build-Module.ps1`, following the same module-build approach used by other Evotec modules such as DnsClientX. + +## Legacy Branch + +The historical script-function implementation is preserved on the `legacy` branch for reference and maintenance history. New development should target `TeamsX`, `TeamsX.PowerShell`, and binary cmdlets rather than adding new PowerShell wrapper functions. ## Links/Blogs @@ -432,11 +460,11 @@ That's it. Whenever there's a new version you simply run the command and you can Remember, that you may need to close, reopen the PowerShell session if you have already used the module before updating it. **The important thing** is if something works for you on production, keep using it till you test the new version on a test computer. I do changes that may not be big, but big enough that auto-update will break your code. For example, small rename to a parameter and your code stops working! Be responsible! -Runtime dependency on `PSSharedGoods` has been removed from the module shell on `main`. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. +Runtime dependency on `PSSharedGoods` has been removed from the module shell. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. -On `main`, the public adaptive surface is now binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. +The public adaptive surface is binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. -`main` also now includes a starter Microsoft Graph delivery path in `TeamsX`, exposed through `New-TeamsGraphTarget`. This lets the typed `Send-TeamsMessage -Message ... -Target ...` path post to Teams chats and channels without introducing large SDK dependencies. +PSTeams also includes a starter Microsoft Graph delivery path in `TeamsX`, exposed through `New-TeamsGraphTarget`. This lets the typed `Send-TeamsMessage -Message ... -Target ...` path post to Teams chats and channels without introducing large SDK dependencies. If you prefer the typed surface directly, the `New-TeamsAdaptive*` cmdlets now expose the richer card and layout options too: From 6486442a9941db4697a64444e798f26af2af8b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 07:47:48 +0200 Subject: [PATCH 06/10] Rewrite README install and quick start sections --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 65a2960..5da0ece 100644 --- a/README.md +++ b/README.md @@ -436,35 +436,60 @@ Send-TeamsMessage ` ![image](https://evotec.xyz/wp-content/uploads/2018/09/img_5b9e830101081.png) -## Installing/Updating on Windows / Linux / MacOS +## Installing and Updating -Installation doesn't require administrative rights. You can install it using following: +PSTeams works on Windows, Linux, and macOS through PowerShell Gallery. Installation does not require administrative rights when you install for the current user. + +Install for the current user: ```powershell -Install-Module PSTeams +Install-Module PSTeams -Scope CurrentUser ``` -But if you don't have administrative rights on your machine: +Install for all users from an elevated PowerShell session: ```powershell -Install-Module PSTeams -Scope CurrentUser +Install-Module PSTeams ``` -To update +Update an existing installation: ```powershell Update-Module -Name PSTeams ``` -That's it. Whenever there's a new version you simply run the command and you can enjoy it. -Remember, that you may need to close, reopen the PowerShell session if you have already used the module before updating it. -**The important thing** is if something works for you on production, keep using it till you test the new version on a test computer. -I do changes that may not be big, but big enough that auto-update will break your code. For example, small rename to a parameter and your code stops working! Be responsible! +After updating, restart any PowerShell session that already imported PSTeams so the new binary module is loaded. +If PSTeams is used in production automations, test new versions before rolling them out broadly. Runtime dependency on `PSSharedGoods` has been removed from the module shell. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. -The public adaptive surface is binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. +## Quick Start + +Send a classic connector-card notification through an incoming webhook: + +```powershell +$target = New-TeamsWebhookTarget -Uri $Env:TEAMS_WEBHOOK_URL +$section = New-TeamsSection ` + -ActivityTitle 'PSTeams' ` + -ActivitySubtitle 'Build notification' ` + -ActivityText 'The release pipeline completed successfully.' ` + -ActivityDetails @( + New-TeamsFact -Name 'Environment' -Value 'Production' + New-TeamsFact -Name 'Result' -Value 'Passed' + ) + +$message = New-TeamsMessage -Title 'Deployment completed' -Text 'PSTeams notification' -Sections $section +Send-TeamsMessage -Message $message -Target $target +``` + +Render the same message as JSON when you want to validate payloads in tests or CI: + +```powershell +$message | ConvertTo-TeamsJson +``` -PSTeams also includes a starter Microsoft Graph delivery path in `TeamsX`, exposed through `New-TeamsGraphTarget`. This lets the typed `Send-TeamsMessage -Message ... -Target ...` path post to Teams chats and channels without introducing large SDK dependencies. +## Typed Adaptive Cards + +The public adaptive surface is binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. If you prefer the typed surface directly, the `New-TeamsAdaptive*` cmdlets now expose the richer card and layout options too: @@ -514,6 +539,8 @@ $json = $message | ConvertTo-TeamsJson Dedicated typed examples live under `Examples\MessageCard\MessageCard-Typed.ps1` and `Examples\Adaptive Card\AdaptiveCard-TypedActions.ps1`. +## Typed Wrapper Cards + Typed wrapper-card models are now available as well: ```powershell @@ -532,6 +559,10 @@ $wrapped = $json | Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Wr Typed wrapper-card direct sending currently targets incoming and workflow webhooks. Graph delivery remains limited to typed messages and adaptive-card attachments. +## Microsoft Graph Delivery + +PSTeams includes a starter Microsoft Graph delivery path in `TeamsX`, exposed through `New-TeamsGraphTarget`. This lets the typed `Send-TeamsMessage -Message ... -Target ...` path post to Teams chats and channels without introducing large SDK dependencies. + For Graph chat or channel delivery, the starter flow looks like this: ```powershell @@ -541,7 +572,7 @@ $target = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessTokenVaria Send-TeamsMessage -Message $message -Target $target ``` -Current Graph scope on `main`: +Current Graph scope: - plain typed messages render to Graph HTML message bodies - adaptive cards render as Graph attachments From 90e6e57e058acd8e32da5eebdc0c110b9d392636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 07:50:51 +0200 Subject: [PATCH 07/10] Move README install and quick start near top --- README.md | 102 +++++++++++++++++++++++++++--------------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 5da0ece..3892f6d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,57 @@ PSTeams uses a cleaner architecture: - Use a starter Microsoft Graph delivery path for Teams chats and channels without adding a large Graph SDK dependency. - Keep the PowerShell module surface familiar while moving implementation into reusable C# cmdlets. +## Installing and Updating + +PSTeams works on Windows, Linux, and macOS through PowerShell Gallery. Installation does not require administrative rights when you install for the current user. + +Install for the current user: + +```powershell +Install-Module PSTeams -Scope CurrentUser +``` + +Install for all users from an elevated PowerShell session: + +```powershell +Install-Module PSTeams +``` + +Update an existing installation: + +```powershell +Update-Module -Name PSTeams +``` + +After updating, restart any PowerShell session that already imported PSTeams so the new binary module is loaded. +If PSTeams is used in production automations, test new versions before rolling them out broadly. +Runtime dependency on `PSSharedGoods` has been removed from the module shell. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. + +## Quick Start + +Send a classic connector-card notification through an incoming webhook: + +```powershell +$target = New-TeamsWebhookTarget -Uri $Env:TEAMS_WEBHOOK_URL +$section = New-TeamsSection ` + -ActivityTitle 'PSTeams' ` + -ActivitySubtitle 'Build notification' ` + -ActivityText 'The release pipeline completed successfully.' ` + -ActivityDetails @( + New-TeamsFact -Name 'Environment' -Value 'Production' + New-TeamsFact -Name 'Result' -Value 'Passed' + ) + +$message = New-TeamsMessage -Title 'Deployment completed' -Text 'PSTeams notification' -Sections $section +Send-TeamsMessage -Message $message -Target $target +``` + +Render the same message as JSON when you want to validate payloads in tests or CI: + +```powershell +$message | ConvertTo-TeamsJson +``` + ## Supported .NET and PowerShell Versions ### TeamsX Library @@ -436,57 +487,6 @@ Send-TeamsMessage ` ![image](https://evotec.xyz/wp-content/uploads/2018/09/img_5b9e830101081.png) -## Installing and Updating - -PSTeams works on Windows, Linux, and macOS through PowerShell Gallery. Installation does not require administrative rights when you install for the current user. - -Install for the current user: - -```powershell -Install-Module PSTeams -Scope CurrentUser -``` - -Install for all users from an elevated PowerShell session: - -```powershell -Install-Module PSTeams -``` - -Update an existing installation: - -```powershell -Update-Module -Name PSTeams -``` - -After updating, restart any PowerShell session that already imported PSTeams so the new binary module is loaded. -If PSTeams is used in production automations, test new versions before rolling them out broadly. -Runtime dependency on `PSSharedGoods` has been removed from the module shell. Development tooling may still use helper modules such as `PSWriteColor`, but the shipping module is being kept as self-contained as possible. - -## Quick Start - -Send a classic connector-card notification through an incoming webhook: - -```powershell -$target = New-TeamsWebhookTarget -Uri $Env:TEAMS_WEBHOOK_URL -$section = New-TeamsSection ` - -ActivityTitle 'PSTeams' ` - -ActivitySubtitle 'Build notification' ` - -ActivityText 'The release pipeline completed successfully.' ` - -ActivityDetails @( - New-TeamsFact -Name 'Environment' -Value 'Production' - New-TeamsFact -Name 'Result' -Value 'Passed' - ) - -$message = New-TeamsMessage -Title 'Deployment completed' -Text 'PSTeams notification' -Sections $section -Send-TeamsMessage -Message $message -Target $target -``` - -Render the same message as JSON when you want to validate payloads in tests or CI: - -```powershell -$message | ConvertTo-TeamsJson -``` - ## Typed Adaptive Cards The public adaptive surface is binary-backed through `TeamsX.PowerShell`. Commands such as `New-AdaptiveCard`, `New-AdaptiveContainer`, `New-AdaptiveColumn`, `New-AdaptiveColumnSet`, `New-AdaptiveTable`, and the rest of the `New-Adaptive*` family are cmdlets rather than script functions. From a300e09a559239e4305b4f4596d33a41e7674f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 15:31:27 +0200 Subject: [PATCH 08/10] Address migration review feedback and CI formatting --- Module/PSTeams/PSTeams.psm1 | 20 +- Module/Tests/Binary.Compose.Tests.ps1 | 12 + .../Legacy.MessageCard.Compose.Tests.ps1 | 40 + TeamsX.PowerShell/CmdletNewAdaptiveCard.cs | 4 +- TeamsX.PowerShell/CmdletNewCardList.cs | 4 +- TeamsX.PowerShell/CmdletNewHeroCard.cs | 4 +- TeamsX.PowerShell/CmdletNewTeamsMessage.cs | 13 +- TeamsX.PowerShell/CmdletNewThumbnailCard.cs | 4 +- TeamsX.PowerShell/CmdletSendTeamsMessage.cs | 212 ++++- .../CmdletSendTeamsMessageBody.cs | 4 +- .../TeamsPowerShellDeliverySupport.cs | 27 +- TeamsX.Tests/GraphMessageRendererTests.cs | 18 + TeamsX.Tests/TeamsColorUtilityTests.cs | 19 + TeamsX/GraphMessageRenderer.cs | 5 +- TeamsX/TeamsColorUtility.cs | 4 + TeamsX/TeamsLegacyColorPalette.cs | 758 ++++++++++++++++++ 16 files changed, 1120 insertions(+), 28 deletions(-) create mode 100644 TeamsX.Tests/TeamsColorUtilityTests.cs create mode 100644 TeamsX/TeamsLegacyColorPalette.cs diff --git a/Module/PSTeams/PSTeams.psm1 b/Module/PSTeams/PSTeams.psm1 index 723526c..f6f339b 100644 --- a/Module/PSTeams/PSTeams.psm1 +++ b/Module/PSTeams/PSTeams.psm1 @@ -9,16 +9,16 @@ $binaryModules = @( $binaryModuleName ) -# Keep the source-tree module usable during development. Prefer the PowerShell 7.x support build. -# Production packaging is handled by Build-Module.ps1. -$development = $true -$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' -$developmentFolderDefault = 'net472' -$preferredDevelopmentCoreFolders = if ([System.Environment]::Version.Major -ge 8) { - @('net8.0', 'net10.0', 'netstandard2.0') -} else { - @('net8.0', 'netstandard2.0') -} +# Keep the source-tree module usable during development. Prefer the PowerShell 7.x support build. +# Production packaging is handled by Build-Module.ps1. +$development = $true +$developmentPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\TeamsX.PowerShell\bin\Debug' +$developmentFolderDefault = 'net472' +$preferredDevelopmentCoreFolders = if ([System.Environment]::Version.Major -ge 8) { + @('net8.0', 'net10.0', 'netstandard2.0') +} else { + @('net8.0', 'netstandard2.0') +} $developmentFolderCore = foreach ($folder in $preferredDevelopmentCoreFolders) { if (Test-Path -LiteralPath (Join-Path -Path $developmentPath -ChildPath "$folder\$binaryModuleName")) { diff --git a/Module/Tests/Binary.Compose.Tests.ps1 b/Module/Tests/Binary.Compose.Tests.ps1 index 3f59718..ae6c821 100644 --- a/Module/Tests/Binary.Compose.Tests.ps1 +++ b/Module/Tests/Binary.Compose.Tests.ps1 @@ -134,6 +134,18 @@ Describe 'TeamsX binary cmdlets through PSTeams' { $json | Should -Match '"@type":"OpenURI"' } + It 'renders connector-card JSON when connector-only fields are set without sections' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $message = New-TeamsMessage -Title 'Build failed' -Text 'Pipeline 42' -Color AlbescentWhite -HideOriginalBody + $json = $message | ConvertTo-TeamsJson + + $json | Should -Match '"themeColor":"#E3DAC9"' + $json | Should -Match '"hideOriginalBody":true' + $json | Should -Match '"title":"Build failed"' + $json | Should -Match '"text":"Pipeline 42"' + } + It 'renders typed wrapper-card objects through ConvertTo-TeamsJson' { Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force diff --git a/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 index d34f32e..9779263 100644 --- a/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 +++ b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 @@ -104,6 +104,46 @@ Describe 'Legacy connector-card migration cmdlets' { $body | Should -Match '!\[Hero\]\(https://example.test/hero.png\)' } + It 'preserves raw dictionary sections in the legacy Send-TeamsMessage scriptblock path' { + $body = Send-TeamsMessage -Uri 'https://example.test/webhook' -MessageTitle 'Build failed' -Suppress:$false -WhatIf { + [ordered]@{ + title = 'Build summary' + text = 'Pipeline failed' + startGroup = $true + facts = @( + [ordered]@{ + name = 'Status' + value = 'Failed' + } + ) + images = @( + [ordered]@{ + image = 'https://example.test/image.png' + } + ) + potentialAction = @( + [ordered]@{ + name = 'Open build' + '@type' = 'OpenURI' + Targets = @( + [ordered]@{ + os = 'default' + uri = 'https://example.test/build/42' + } + ) + } + ) + } + } + + $body | Should -Match '"title":"Build summary"' + $body | Should -Match '"text":"Pipeline failed"' + $body | Should -Match '"startGroup":true' + $body | Should -Match '"name":"Status"' + $body | Should -Match '"image":"https://example.test/image.png"' + $body | Should -Match '"@type":"OpenURI"' + } + It 'wraps raw attachment bodies without sending when using WhatIf' { $body = Send-TeamsMessageBody -Uri 'https://example.test/webhook' -Body '{"contentType":"application/vnd.microsoft.card.hero"}' -Wrap -Supress:$false -WhatIf diff --git a/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs b/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs index c3ce5d3..7ae9fc6 100644 --- a/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs +++ b/TeamsX.PowerShell/CmdletNewAdaptiveCard.cs @@ -134,9 +134,9 @@ protected override void ProcessRecord() { return; } - var client = TeamsPowerShellDeliverySupport.CreateClient(null); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(null); var target = TeamsMessageTarget.ForIncomingWebhook(Uri); - var result = client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-AdaptiveCard"); diff --git a/TeamsX.PowerShell/CmdletNewCardList.cs b/TeamsX.PowerShell/CmdletNewCardList.cs index 67ed440..86cbdb6 100644 --- a/TeamsX.PowerShell/CmdletNewCardList.cs +++ b/TeamsX.PowerShell/CmdletNewCardList.cs @@ -74,10 +74,10 @@ private void ApplyItem(TeamsListCard card, object? value) { } private void SendAttachmentBody(string attachmentBody, Uri uri) { - var client = TeamsPowerShellDeliverySupport.CreateClient(null); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(null); var target = TeamsMessageTarget.ForIncomingWebhook(uri); var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); - var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-CardList"); } diff --git a/TeamsX.PowerShell/CmdletNewHeroCard.cs b/TeamsX.PowerShell/CmdletNewHeroCard.cs index 8be202f..d7e2162 100644 --- a/TeamsX.PowerShell/CmdletNewHeroCard.cs +++ b/TeamsX.PowerShell/CmdletNewHeroCard.cs @@ -92,10 +92,10 @@ private void ApplyItem(TeamsHeroCard card, object? value) { } private void SendAttachmentBody(string attachmentBody, Uri uri) { - var client = TeamsPowerShellDeliverySupport.CreateClient(null); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(null); var target = TeamsMessageTarget.ForIncomingWebhook(uri); var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); - var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-HeroCard"); } diff --git a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs index 14ca619..39125d9 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs @@ -39,7 +39,7 @@ protected override void ProcessRecord() { AdaptiveCard = AdaptiveCard, ThemeColor = ResolveThemeColor(), HideOriginalBody = HideOriginalBody.IsPresent, - UseConnectorCardFormat = UseConnectorCardFormat.IsPresent || (AdaptiveCard is null && Sections.Length > 0) + UseConnectorCardFormat = ShouldUseConnectorCardFormat() }; foreach (var section in Sections) { @@ -58,4 +58,15 @@ protected override void ProcessRecord() { return TeamsColorUtility.NormalizeToHex(ThemeColor); } + + private bool ShouldUseConnectorCardFormat() { + if (UseConnectorCardFormat.IsPresent) { + return true; + } + + return AdaptiveCard is null && + (Sections.Length > 0 || + !string.IsNullOrWhiteSpace(ThemeColor) || + HideOriginalBody.IsPresent); + } } diff --git a/TeamsX.PowerShell/CmdletNewThumbnailCard.cs b/TeamsX.PowerShell/CmdletNewThumbnailCard.cs index 58fcc28..ce047bb 100644 --- a/TeamsX.PowerShell/CmdletNewThumbnailCard.cs +++ b/TeamsX.PowerShell/CmdletNewThumbnailCard.cs @@ -92,10 +92,10 @@ private void ApplyItem(TeamsThumbnailCard card, object? value) { } private void SendAttachmentBody(string attachmentBody, Uri uri) { - var client = TeamsPowerShellDeliverySupport.CreateClient(null); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(null); var target = TeamsMessageTarget.ForIncomingWebhook(uri); var wrappedBody = TeamsWrapperCardRenderer.WrapAsMessage(attachmentBody); - var result = client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendJsonAsync(wrappedBody, target).GetAwaiter().GetResult(); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "New-ThumbnailCard"); } diff --git a/TeamsX.PowerShell/CmdletSendTeamsMessage.cs b/TeamsX.PowerShell/CmdletSendTeamsMessage.cs index 6b3b88e..45a3582 100644 --- a/TeamsX.PowerShell/CmdletSendTeamsMessage.cs +++ b/TeamsX.PowerShell/CmdletSendTeamsMessage.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using System.Collections; using TeamsX; namespace TeamsX.PowerShell; @@ -127,9 +128,9 @@ private void ProcessLegacyRecord() { return; } - var client = TeamsPowerShellDeliverySupport.CreateClient(Proxy); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(Proxy); var target = TeamsMessageTarget.ForIncomingWebhook(Uri); - var result = client.SendAsync(request, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendAsync(request, target).GetAwaiter().GetResult(); WriteVerbose($"Send-TeamsMessage - Execute {result.ResponseBody}"); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "Send-TeamsMessage"); @@ -150,7 +151,7 @@ private IEnumerable ResolveLegacySections() { return SectionsInput .Invoke() - .Select(item => item?.BaseObject) + .Select(item => ConvertLegacySection(item?.BaseObject)) .OfType() .ToArray(); } @@ -185,4 +186,209 @@ private string GetTypedPayloadName() { _ => "Teams message" }; } + + private static TeamsMessageSection? ConvertLegacySection(object? value) { + return value switch { + null => null, + TeamsMessageSection section => section, + IDictionary dictionary => ConvertLegacySectionDictionary(dictionary), + _ => null + }; + } + + private static TeamsMessageSection ConvertLegacySectionDictionary(IDictionary dictionary) { + var section = new TeamsMessageSection { + Title = ReadString(dictionary, "title"), + ActivityTitle = ReadString(dictionary, "activityTitle"), + ActivitySubtitle = ReadString(dictionary, "activitySubtitle"), + ActivityImage = ReadString(dictionary, "activityImage"), + ActivityText = ReadString(dictionary, "activityText"), + Text = ReadString(dictionary, "text"), + StartGroup = ReadBool(dictionary, "startGroup") + }; + + foreach (var fact in ReadDictionaryArray(dictionary, "facts")) { + section.Facts.Add(new TeamsMessageFact { + Name = ReadString(fact, "name"), + Value = ReadString(fact, "value") + }); + } + + foreach (var action in ReadObjectArray(dictionary, "potentialAction")) { + var button = ConvertLegacyButton(action); + if (button is not null) { + section.Buttons.Add(button); + } + } + + foreach (var image in ReadObjectArray(dictionary, "images")) { + var imageUri = image switch { + string value => value, + IDictionary imageDictionary => ReadString(imageDictionary, "image"), + _ => null + }; + + if (!string.IsNullOrWhiteSpace(imageUri)) { + section.Images.Add(imageUri!); + } + } + + return section; + } + + private static TeamsMessageButton? ConvertLegacyButton(object? value) { + if (value is TeamsMessageButton button) { + return button; + } + + if (value is not IDictionary dictionary) { + return null; + } + + var buttonTypeName = ReadString(dictionary, "@type") ?? ReadString(dictionary, "type"); + var buttonType = ResolveLegacyButtonType(buttonTypeName, dictionary); + var buttonLink = ResolveLegacyButtonLink(dictionary); + + return new TeamsMessageButton { + Name = ReadString(dictionary, "name") ?? ReadString(dictionary, "Name"), + Link = buttonLink, + ButtonType = buttonType + }; + } + + private static TeamsMessageButtonType ResolveLegacyButtonType(string? typeName, IDictionary dictionary) { + if (string.Equals(typeName, "ActionCard", StringComparison.OrdinalIgnoreCase)) { + var inputType = ReadNestedActionInputType(dictionary); + if (string.Equals(inputType, "TextInput", StringComparison.OrdinalIgnoreCase)) { + return TeamsMessageButtonType.TextInput; + } + + if (string.Equals(inputType, "DateInput", StringComparison.OrdinalIgnoreCase)) { + return TeamsMessageButtonType.DateInput; + } + + return TeamsMessageButtonType.HttpPost; + } + + return typeName?.ToUpperInvariant() switch { + "VIEWACTION" => TeamsMessageButtonType.ViewAction, + "HTTPPOST" => TeamsMessageButtonType.HttpPost, + "OPENURI" => TeamsMessageButtonType.OpenUri, + _ => TeamsMessageButtonType.ViewAction + }; + } + + private static string? ReadNestedActionInputType(IDictionary dictionary) { + foreach (var input in ReadObjectArray(dictionary, "Inputs")) { + if (input is IDictionary inputDictionary) { + return ReadString(inputDictionary, "@type"); + } + } + + return null; + } + + private static string? ResolveLegacyButtonLink(IDictionary dictionary) { + var directTarget = ReadFirstString(dictionary, "target", "Target"); + if (!string.IsNullOrWhiteSpace(directTarget)) { + return directTarget; + } + + foreach (var target in ReadObjectArray(dictionary, "Targets")) { + if (target is IDictionary targetDictionary) { + var uri = ReadString(targetDictionary, "uri"); + if (!string.IsNullOrWhiteSpace(uri)) { + return uri; + } + } + } + + foreach (var action in ReadObjectArray(dictionary, "actions")) { + if (action is IDictionary actionDictionary) { + var actionTarget = ReadFirstString(actionDictionary, "target", "Target"); + if (!string.IsNullOrWhiteSpace(actionTarget)) { + return actionTarget; + } + } + } + + return null; + } + + private static IEnumerable ReadDictionaryArray(IDictionary dictionary, string key) { + return ReadObjectArray(dictionary, key).OfType(); + } + + private static IEnumerable ReadObjectArray(IDictionary dictionary, string key) { + if (!TryGetValue(dictionary, key, out var value) || value is null) { + return Array.Empty(); + } + + if (value is string) { + return new object[] { value }; + } + + if (value is IEnumerable enumerable) { + return enumerable.Cast(); + } + + return new object[] { value }; + } + + private static string? ReadFirstString(IDictionary dictionary, params string[] keys) { + foreach (var key in keys) { + if (!TryGetValue(dictionary, key, out var value) || value is null) { + continue; + } + + if (value is string text) { + return text; + } + + if (value is IEnumerable enumerable and not string) { + foreach (var item in enumerable) { + if (item is string itemText && !string.IsNullOrWhiteSpace(itemText)) { + return itemText; + } + } + } + } + + return null; + } + + private static string? ReadString(IDictionary dictionary, string key) { + if (!TryGetValue(dictionary, key, out var value) || value is null) { + return null; + } + + return value switch { + string text => text, + _ => value.ToString() + }; + } + + private static bool ReadBool(IDictionary dictionary, string key) { + if (!TryGetValue(dictionary, key, out var value) || value is null) { + return false; + } + + return value switch { + bool result => result, + string text when bool.TryParse(text, out var parsed) => parsed, + _ => false + }; + } + + private static bool TryGetValue(IDictionary dictionary, string key, out object? value) { + foreach (DictionaryEntry entry in dictionary) { + if (string.Equals(entry.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase)) { + value = entry.Value; + return true; + } + } + + value = null; + return false; + } } diff --git a/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs b/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs index cea0bc2..9eaf87b 100644 --- a/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs +++ b/TeamsX.PowerShell/CmdletSendTeamsMessageBody.cs @@ -42,9 +42,9 @@ protected override void ProcessRecord() { return; } - var client = TeamsPowerShellDeliverySupport.CreateClient(Proxy); + using var clientLease = TeamsPowerShellDeliverySupport.CreateClientLease(Proxy); var target = TeamsMessageTarget.ForIncomingWebhook(Uri); - var result = client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); + var result = clientLease.Client.SendJsonAsync(jsonBody, target).GetAwaiter().GetResult(); WriteVerbose($"Send-TeamsMessageBody - Execute {result.ResponseBody}"); TeamsPowerShellDeliverySupport.WriteDeliveryIssue(this, result, "Send-TeamsMessageBody"); diff --git a/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs b/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs index a6a9f29..a45edb1 100644 --- a/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs +++ b/TeamsX.PowerShell/TeamsPowerShellDeliverySupport.cs @@ -6,9 +6,9 @@ namespace TeamsX.PowerShell; internal static class TeamsPowerShellDeliverySupport { - public static TeamsClient CreateClient(Uri? proxy) { + public static TeamsClientLease CreateClientLease(Uri? proxy) { if (proxy is null) { - return TeamsClient.Default; + return TeamsClientLease.SharedDefault; } var handler = new HttpClientHandler { @@ -18,7 +18,9 @@ public static TeamsClient CreateClient(Uri? proxy) { var httpClient = new HttpClient(handler, disposeHandler: true); var sender = new WebhookTeamsMessageSender(httpClient, disposeHttpClient: true); - return new TeamsClient(new ITeamsMessageSender[] { sender }); + return new TeamsClientLease( + new TeamsClient(new ITeamsMessageSender[] { sender }), + sender); } public static void WriteDeliveryIssue(PSCmdlet cmdlet, TeamsDeliveryResult result, string commandName) { @@ -61,3 +63,22 @@ public static bool LooksLikeFailureMessage(string? responseBody) { body.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0; } } + +internal sealed class TeamsClientLease : IDisposable { + public static TeamsClientLease SharedDefault { get; } = new(TeamsClient.Default); + + private readonly IDisposable[] _disposables; + + public TeamsClientLease(TeamsClient client, params IDisposable[] disposables) { + Client = client ?? throw new ArgumentNullException(nameof(client)); + _disposables = disposables ?? Array.Empty(); + } + + public TeamsClient Client { get; } + + public void Dispose() { + foreach (var disposable in _disposables) { + disposable.Dispose(); + } + } +} diff --git a/TeamsX.Tests/GraphMessageRendererTests.cs b/TeamsX.Tests/GraphMessageRendererTests.cs index d37a661..b1d275a 100644 --- a/TeamsX.Tests/GraphMessageRendererTests.cs +++ b/TeamsX.Tests/GraphMessageRendererTests.cs @@ -82,4 +82,22 @@ public void RenderAdaptiveCardMessageRejectsUnsupportedActions() { Assert.Throws(action); } + + [Fact] + public void RenderHtmlMessageUsesSummaryWhenSectionsRenderEmptyFragments() { + var request = new TeamsMessageRequest { + Summary = "Fallback summary" + }; + request.Sections.Add(new TeamsMessageSection { + StartGroup = true + }); + + var json = GraphMessageRenderer.Render(request, TeamsDeliveryMethod.GraphChatMessage); + using var document = JsonDocument.Parse(json); + + var body = document.RootElement.GetProperty("body"); + var html = body.GetProperty("content").GetString(); + + Assert.Contains("Fallback summary", html); + } } diff --git a/TeamsX.Tests/TeamsColorUtilityTests.cs b/TeamsX.Tests/TeamsColorUtilityTests.cs new file mode 100644 index 0000000..33b6894 --- /dev/null +++ b/TeamsX.Tests/TeamsColorUtilityTests.cs @@ -0,0 +1,19 @@ +using TeamsX; + +namespace TeamsX.Tests; + +public class TeamsColorUtilityTests { + [Fact] + public void NormalizeToHexSupportsLegacyPSTeamsPaletteNames() { + var hex = TeamsColorUtility.NormalizeToHex("AlbescentWhite"); + + Assert.Equal("#E3DAC9", hex); + } + + [Fact] + public void NormalizeToHexSupportsSystemDrawingNamedColors() { + var hex = TeamsColorUtility.NormalizeToHex("DodgerBlue"); + + Assert.Equal("#1E90FF", hex); + } +} diff --git a/TeamsX/GraphMessageRenderer.cs b/TeamsX/GraphMessageRenderer.cs index 0240e22..0f9860d 100644 --- a/TeamsX/GraphMessageRenderer.cs +++ b/TeamsX/GraphMessageRenderer.cs @@ -74,7 +74,10 @@ private static List BuildBodyFragments(TeamsMessageRequest request) { } foreach (var section in request.Sections) { - fragments.Add(RenderSection(section)); + var sectionFragment = RenderSection(section); + if (!string.IsNullOrWhiteSpace(sectionFragment)) { + fragments.Add(sectionFragment); + } } if (fragments.Count == 0 && !string.IsNullOrWhiteSpace(request.Summary)) { diff --git a/TeamsX/TeamsColorUtility.cs b/TeamsX/TeamsColorUtility.cs index e4855fc..abef763 100644 --- a/TeamsX/TeamsColorUtility.cs +++ b/TeamsX/TeamsColorUtility.cs @@ -17,6 +17,10 @@ public static class TeamsColorUtility { : throw new ArgumentException("The Input value is not a valid colorname nor an valid color hex code.", nameof(color)); } + if (TeamsLegacyColorPalette.Colors.TryGetValue(candidate, out var legacyHex)) { + return legacyHex; + } + var resolved = Color.FromName(candidate); if (resolved.ToArgb() == 0 && !string.Equals(candidate, "Transparent", StringComparison.OrdinalIgnoreCase)) { diff --git a/TeamsX/TeamsLegacyColorPalette.cs b/TeamsX/TeamsLegacyColorPalette.cs new file mode 100644 index 0000000..975ab65 --- /dev/null +++ b/TeamsX/TeamsLegacyColorPalette.cs @@ -0,0 +1,758 @@ +namespace TeamsX; + +/// +/// Legacy PSTeams named colors preserved for 1:1 migration compatibility. +/// +internal static class TeamsLegacyColorPalette { + public static IReadOnlyDictionary Colors { get; } = new Dictionary(System.StringComparer.OrdinalIgnoreCase) { + ["AirForceBlue"] = "#5D8AA8", + ["Akaroa"] = "#C3B091", + ["AlbescentWhite"] = "#E3DAC9", + ["AliceBlue"] = "#F0F8FF", + ["Alizarin"] = "#E32636", + ["Allports"] = "#126180", + ["Almond"] = "#EFDECD", + ["AlmondFrost"] = "#9F8170", + ["Amaranth"] = "#E52B50", + ["Amazon"] = "#3B7A57", + ["Amber"] = "#FFBF00", + ["Amethyst"] = "#9966CC", + ["AmethystSmoke"] = "#9C8AA4", + ["AntiqueWhite"] = "#FAEBD7", + ["Apple"] = "#66B447", + ["AppleBlossom"] = "#B05C52", + ["Apricot"] = "#FBCEB1", + ["Aqua"] = "#00FFFF", + ["Aquamarine"] = "#7FFFD4", + ["Armygreen"] = "#4B5320", + ["Arsenic"] = "#3B444B", + ["Astral"] = "#367588", + ["Atlantis"] = "#A4C639", + ["Atomic"] = "#414A4C", + ["AtomicTangerine"] = "#FF9966", + ["Axolotl"] = "#63775B", + ["Azure"] = "#F0FFFF", + ["Bahia"] = "#B0BF1A", + ["BakersChocolate"] = "#5D3A1A", + ["BaliHai"] = "#7C98AB", + ["BananaMania"] = "#FAE7B5", + ["BattleshipGrey"] = "#555D50", + ["BayOfMany"] = "#233067", + ["Beige"] = "#F5F5DC", + ["Bermuda"] = "#88D8C0", + ["Bilbao"] = "#2A8000", + ["BilobaFlower"] = "#B57EDC", + ["Bismark"] = "#536872", + ["Bisque"] = "#FFE4C4", + ["Bistre"] = "#3D2B1F", + ["Bittersweet"] = "#FE6F5E", + ["Black"] = "#000000", + ["BlackPearl"] = "#1F262A", + ["BlackRose"] = "#551F2F", + ["BlackRussian"] = "#17182B", + ["BlanchedAlmond"] = "#FFEBCD", + ["BlizzardBlue"] = "#ACE5EE", + ["Blue"] = "#0000FF", + ["BlueDiamond"] = "#4D1A7F", + ["BlueMarguerite"] = "#7366BD", + ["BlueSmoke"] = "#738276", + ["BlueViolet"] = "#8A2BE2", + ["Blush"] = "#A95C68", + ["BokaraGrey"] = "#16110D", + ["Bole"] = "#79443B", + ["BondiBlue"] = "#0093AF", + ["Bordeaux"] = "#58111A", + ["Bossanova"] = "#563C5C", + ["Boulder"] = "#727472", + ["Bouquet"] = "#B784A7", + ["Bourbon"] = "#AA6C39", + ["Brass"] = "#B5A642", + ["BrickRed"] = "#C72C48", + ["BrightGreen"] = "#66FF00", + ["BrightRed"] = "#922B3E", + ["BrightTurquoise"] = "#08E8DE", + ["BrilliantRose"] = "#F364A2", + ["BrinkPink"] = "#FA6E79", + ["BritishRacingGreen"] = "#004225", + ["Bronze"] = "#CD7F32", + ["Brown"] = "#A52A2A", + ["BrownPod"] = "#391802", + ["BuddhaGold"] = "#CAA906", + ["Buff"] = "#F0DC82", + ["Burgundy"] = "#800020", + ["BurlyWood"] = "#DEB887", + ["BurntOrange"] = "#FF7538", + ["BurntSienna"] = "#E97451", + ["BurntUmber"] = "#8A3324", + ["ButteredRum"] = "#9C7C38", + ["CadetBlue"] = "#5F9EA0", + ["California"] = "#E08D3C", + ["CamouflageGreen"] = "#78866B", + ["Canary"] = "#FFFF99", + ["CanCan"] = "#D98695", + ["CannonPink"] = "#914E75", + ["CaputMortuum"] = "#592720", + ["Caramel"] = "#FFD59A", + ["Cararra"] = "#EDE6D6", + ["Cardinal"] = "#B32134", + ["CardinGreen"] = "#123524", + ["CareysPink"] = "#D998A0", + ["CaribbeanGreen"] = "#00DEA4", + ["Carmine"] = "#AF002A", + ["CarnationPink"] = "#FFA6C9", + ["CarrotOrange"] = "#F28E1C", + ["Cascade"] = "#8DA399", + ["CatskillWhite"] = "#E2E5DE", + ["Cedar"] = "#43302E", + ["Celadon"] = "#ACE1AF", + ["Celeste"] = "#CFCFC4", + ["Cello"] = "#374F6B", + ["Cement"] = "#8A795D", + ["Cerise"] = "#DE3163", + ["Cerulean"] = "#007BA7", + ["CeruleanBlue"] = "#2A52BE", + ["Chantilly"] = "#EFBBCC", + ["Chardonnay"] = "#FFC87C", + ["Charlotte"] = "#A7D8DE", + ["Charm"] = "#D0748B", + ["Chartreuse"] = "#7FFF00", + ["ChartreuseYellow"] = "#DFFF00", + ["ChelseaCucumber"] = "#87A96B", + ["Cherub"] = "#F6D6DE", + ["Chestnut"] = "#B94E48", + ["ChileanFire"] = "#E25822", + ["Chinook"] = "#96C8A2", + ["Chocolate"] = "#D2691E", + ["Christi"] = "#7DB700", + ["Christine"] = "#B5651E", + ["Cinnabar"] = "#EB4C42", + ["Citron"] = "#9FA91F", + ["Citrus"] = "#8DB600", + ["Claret"] = "#5F1933", + ["ClassicRose"] = "#FBCCE7", + ["ClayCreek"] = "#918151", + ["Clinker"] = "#4B3621", + ["Clover"] = "#4A5D23", + ["Cobalt"] = "#0047AB", + ["CocoaBrown"] = "#2C1608", + ["Cola"] = "#3C3024", + ["ColumbiaBlue"] = "#A6E7FF", + ["CongoBrown"] = "#674C47", + ["Conifer"] = "#B2EC5D", + ["Copper"] = "#DA8A67", + ["CopperRose"] = "#996666", + ["Coral"] = "#FF7F50", + ["CoralRed"] = "#FF4040", + ["CoralTree"] = "#AD6F69", + ["Coriander"] = "#BCB88A", + ["Corn"] = "#FBEC5D", + ["CornField"] = "#FAF0BE", + ["Cornflower"] = "#93CCEA", + ["CornflowerBlue"] = "#6495ED", + ["Cornsilk"] = "#FFF8DC", + ["Cosmic"] = "#843F5B", + ["Cosmos"] = "#FFCCCB", + ["CostaDelSol"] = "#665D1E", + ["CottonCandy"] = "#FFBCD9", + ["Crail"] = "#A45A52", + ["Cranberry"] = "#CD607E", + ["Cream"] = "#FFFFCC", + ["CreamCan"] = "#F2C649", + ["Crimson"] = "#DC143C", + ["Crusta"] = "#E88E5A", + ["Cumulus"] = "#FFFFBF", + ["Cupid"] = "#F6ADC6", + ["CuriousBlue"] = "#2887C8", + ["Cyan"] = "#00FFFF", + ["Cyprus"] = "#064E40", + ["DaisyBush"] = "#553592", + ["Dandelion"] = "#FADA5E", + ["Danube"] = "#6082B6", + ["DarkBlue"] = "#00008B", + ["DarkBrown"] = "#654321", + ["DarkCerulean"] = "#08457E", + ["DarkChestnut"] = "#986960", + ["DarkCoral"] = "#C95A49", + ["DarkCyan"] = "#008B8B", + ["DarkGoldenrod"] = "#B8860B", + ["DarkGray"] = "#A9A9A9", + ["DarkGreen"] = "#006400", + ["DarkGreenCopper"] = "#49796B", + ["DarkGrey"] = "#A9A9A9", + ["DarkKhaki"] = "#BDB76B", + ["DarkMagenta"] = "#8B008B", + ["DarkOliveGreen"] = "#556B2F", + ["DarkOrange"] = "#FF8C00", + ["DarkOrchid"] = "#9932CC", + ["DarkPastelGreen"] = "#03C03C", + ["DarkPink"] = "#DE5D83", + ["DarkPurple"] = "#963D7F", + ["DarkRed"] = "#8B0000", + ["DarkSalmon"] = "#E9967A", + ["DarkSeaGreen"] = "#8FBC8F", + ["DarkSlateBlue"] = "#483D8B", + ["DarkSlateGray"] = "#2F4F4F", + ["DarkSlateGrey"] = "#2F4F4F", + ["DarkSpringGreen"] = "#177245", + ["DarkTangerine"] = "#FFAA1D", + ["DarkTurquoise"] = "#00CED1", + ["DarkViolet"] = "#9400D3", + ["DarkWood"] = "#826644", + ["DeepBlush"] = "#F56991", + ["DeepCerise"] = "#E0218A", + ["DeepKoamaru"] = "#333366", + ["DeepLilac"] = "#9955BB", + ["DeepMagenta"] = "#CC00CC", + ["DeepPink"] = "#FF1493", + ["DeepSea"] = "#0E7C61", + ["DeepSkyBlue"] = "#00BFFF", + ["DeepTeal"] = "#18453B", + ["Denim"] = "#246BCE", + ["DesertSand"] = "#EDC9AF", + ["DimGray"] = "#696969", + ["DimGrey"] = "#696969", + ["DodgerBlue"] = "#1E90FF", + ["Dolly"] = "#F2F27A", + ["Downy"] = "#5FC9BF", + ["DutchWhite"] = "#EFDFBB", + ["EastBay"] = "#4C516D", + ["EastSide"] = "#B284BE", + ["EchoBlue"] = "#A9B2C3", + ["Ecru"] = "#C2B280", + ["Eggplant"] = "#A2006D", + ["EgyptianBlue"] = "#1034A6", + ["ElectricBlue"] = "#7DF9FF", + ["ElectricIndigo"] = "#6F00FF", + ["ElectricLime"] = "#D0FF14", + ["ElectricPurple"] = "#BF00FF", + ["Elm"] = "#2F847C", + ["Emerald"] = "#50C878", + ["Eminence"] = "#6C3082", + ["Endeavour"] = "#2E5894", + ["EnergyYellow"] = "#F5E050", + ["Espresso"] = "#4A2C2A", + ["Eucalyptus"] = "#1AA260", + ["Falcon"] = "#7E5E60", + ["Fallow"] = "#CC9966", + ["FaluRed"] = "#801818", + ["Feldgrau"] = "#4D5D53", + ["Feldspar"] = "#CD9575", + ["Fern"] = "#71BC78", + ["FernGreen"] = "#4F7942", + ["Festival"] = "#ECD540", + ["Finn"] = "#614051", + ["FireBrick"] = "#B22222", + ["FireBush"] = "#DE8F4E", + ["FireEngineRed"] = "#D3212D", + ["Flamingo"] = "#E95C4B", + ["Flax"] = "#EEDC82", + ["FloralWhite"] = "#FFFAF0", + ["ForestGreen"] = "#228B22", + ["Frangipani"] = "#FAD6A5", + ["FreeSpeechAquamarine"] = "#00A877", + ["FreeSpeechRed"] = "#CC0000", + ["FrenchLilac"] = "#E6A8D7", + ["FrenchRose"] = "#E85395", + ["FriarGrey"] = "#878681", + ["Froly"] = "#E4717A", + ["Fuchsia"] = "#FF00FF", + ["FuchsiaPink"] = "#FF77FF", + ["Gainsboro"] = "#DCDCDC", + ["Gallery"] = "#DBD7D2", + ["Galliano"] = "#CCA01D", + ["Gamboge"] = "#CC9900", + ["Ghost"] = "#C4C3D0", + ["GhostWhite"] = "#F8F8FF", + ["Gin"] = "#D8E4BC", + ["GinFizz"] = "#F7E7CE", + ["Givry"] = "#E6D0AB", + ["Glacier"] = "#73A9C2", + ["Gold"] = "#FFD700", + ["GoldDrop"] = "#D56C2B", + ["GoldenBrown"] = "#967117", + ["GoldenFizz"] = "#F0E130", + ["GoldenGlow"] = "#F8DE7E", + ["GoldenPoppy"] = "#FCC200", + ["Goldenrod"] = "#DAA520", + ["GoldenSand"] = "#E9D66B", + ["GoldenYellow"] = "#FDEE00", + ["GoldTips"] = "#E1BD27", + ["GordonsGreen"] = "#253529", + ["Gorse"] = "#FFE135", + ["Gossamer"] = "#319177", + ["GrannySmithApple"] = "#A8E4A0", + ["Gray"] = "#808080", + ["GrayAsparagus"] = "#465945", + ["Green"] = "#008000", + ["GreenLeaf"] = "#4C721D", + ["GreenVogue"] = "#264348", + ["GreenYellow"] = "#ADFF2F", + ["Grey"] = "#808080", + ["GreyAsparagus"] = "#465945", + ["GuardsmanRed"] = "#9D2933", + ["GumLeaf"] = "#B2BEB5", + ["Gunmetal"] = "#2A3439", + ["Hacienda"] = "#9B870C", + ["HalfAndHalf"] = "#E8E4C9", + ["HalfBaked"] = "#5F8A8B", + ["HalfColonialWhite"] = "#F6EABE", + ["HalfPearlLusta"] = "#F0EAD6", + ["HanPurple"] = "#3F00FF", + ["Harlequin"] = "#4AFF00", + ["HarleyDavidsonOrange"] = "#C23B22", + ["Heather"] = "#AEC6CF", + ["Heliotrope"] = "#DF73FF", + ["Hemp"] = "#A17A74", + ["Highball"] = "#867E36", + ["HippiePink"] = "#AB4B52", + ["Hoki"] = "#6E7F80", + ["HollywoodCerise"] = "#F400A1", + ["Honeydew"] = "#F0FFF0", + ["Hopbush"] = "#CF71AF", + ["HorsesNeck"] = "#6C541E", + ["HotPink"] = "#FF69B4", + ["HummingBird"] = "#C9FFE5", + ["HunterGreen"] = "#355E3B", + ["Illusion"] = "#F498AD", + ["InchWorm"] = "#CAE00D", + ["IndianRed"] = "#CD5C5C", + ["Indigo"] = "#4B0082", + ["InternationalKleinBlue"] = "#0018A8", + ["InternationalOrange"] = "#FF4F00", + ["IrisBlue"] = "#1CA9C9", + ["IrishCoffee"] = "#664228", + ["IronsideGrey"] = "#71706E", + ["IslamicGreen"] = "#009000", + ["Ivory"] = "#FFFFF0", + ["Jacarta"] = "#3D325D", + ["JackoBean"] = "#413628", + ["JacksonsPurple"] = "#2E2D88", + ["Jade"] = "#00AB66", + ["JapaneseLaurel"] = "#2F7532", + ["Jazz"] = "#5D2B2C", + ["JazzberryJam"] = "#A50B5E", + ["JellyBean"] = "#44798E", + ["JetStream"] = "#BBD0C9", + ["Jewel"] = "#006B3C", + ["Jon"] = "#4F3A3C", + ["JordyBlue"] = "#7CB9E8", + ["Jumbo"] = "#848482", + ["JungleGreen"] = "#29AB87", + ["KaitokeGreen"] = "#1E4D2B", + ["Karry"] = "#FFDDCA", + ["KellyGreen"] = "#46CB18", + ["Keppel"] = "#5DA493", + ["Khaki"] = "#F0E68C", + ["Killarney"] = "#4D8C57", + ["KingfisherDaisy"] = "#551B8C", + ["Kobi"] = "#E68FAC", + ["LaPalma"] = "#3C8D0D", + ["LaserLemon"] = "#FCF75E", + ["Laurel"] = "#679267", + ["Lavender"] = "#E6E6FA", + ["LavenderBlue"] = "#CCCCFF", + ["LavenderBlush"] = "#FFF0F5", + ["LavenderPink"] = "#FBAED2", + ["LavenderRose"] = "#FBA0E3", + ["LawnGreen"] = "#7CFC00", + ["LemonChiffon"] = "#FFFACD", + ["LightBlue"] = "#ADD8E6", + ["LightCoral"] = "#F08080", + ["LightCyan"] = "#E0FFFF", + ["LightGoldenrodYellow"] = "#FAFAD2", + ["LightGray"] = "#D3D3D3", + ["LightGreen"] = "#90EE90", + ["LightGrey"] = "#D3D3D3", + ["LightPink"] = "#FFB6C1", + ["LightSalmon"] = "#FFA07A", + ["LightSeaGreen"] = "#20B2AA", + ["LightSkyBlue"] = "#87CEFA", + ["LightSlateGray"] = "#778899", + ["LightSlateGrey"] = "#778899", + ["LightSteelBlue"] = "#B0C4DE", + ["LightYellow"] = "#FFFFE0", + ["Lilac"] = "#CC99CC", + ["Lime"] = "#00FF00", + ["LimeGreen"] = "#32CD32", + ["Limerick"] = "#8BBE1B", + ["Linen"] = "#FAF0E6", + ["Lipstick"] = "#9F2B68", + ["Liver"] = "#534B4F", + ["Lochinvar"] = "#56887D", + ["Lochmara"] = "#26619C", + ["Lola"] = "#B39EB5", + ["LondonHue"] = "#AA98A9", + ["Lotus"] = "#7C4848", + ["LuckyPoint"] = "#1D2951", + ["MacaroniAndCheese"] = "#FFBD88", + ["Madang"] = "#C1F9A2", + ["Madras"] = "#514100", + ["Magenta"] = "#FF00FF", + ["MagicMint"] = "#AAF0D1", + ["Magnolia"] = "#F8F4FF", + ["Mahogany"] = "#D73B3E", + ["Maire"] = "#1B1811", + ["Maize"] = "#E6BE8A", + ["Malachite"] = "#0BDA51", + ["Malibu"] = "#5DADEC", + ["Malta"] = "#A99A86", + ["Manatee"] = "#8C92AC", + ["Mandalay"] = "#B07939", + ["MandarianOrange"] = "#922724", + ["Mandy"] = "#BF4F51", + ["Manhattan"] = "#E5AA70", + ["Mantis"] = "#7DC242", + ["Manz"] = "#D9E650", + ["MardiGras"] = "#301934", + ["Mariner"] = "#39569C", + ["Maroon"] = "#800000", + ["Matterhorn"] = "#555555", + ["Mauve"] = "#F4BBFF", + ["Mauvelous"] = "#FF91AF", + ["MauveTaupe"] = "#8F5973", + ["MayaBlue"] = "#77B5FE", + ["McKenzie"] = "#81613C", + ["MediumAquamarine"] = "#66CDAA", + ["MediumBlue"] = "#0000CD", + ["MediumCarmine"] = "#AF4035", + ["MediumOrchid"] = "#BA55D3", + ["MediumPurple"] = "#9370DB", + ["MediumRedViolet"] = "#BD33A4", + ["MediumSeaGreen"] = "#3CB371", + ["MediumSlateBlue"] = "#7B68EE", + ["MediumSpringGreen"] = "#00FA9A", + ["MediumTurquoise"] = "#48D1CC", + ["MediumVioletRed"] = "#C71585", + ["MediumWood"] = "#A67B5B", + ["Melon"] = "#FDBCB4", + ["Merlot"] = "#703642", + ["MetallicGold"] = "#D3AF37", + ["Meteor"] = "#B87333", + ["MidnightBlue"] = "#191970", + ["MidnightExpress"] = "#001440", + ["Mikado"] = "#3C341F", + ["MilanoRed"] = "#A83731", + ["Ming"] = "#36747D", + ["MintCream"] = "#F5FFFA", + ["MintGreen"] = "#98FF98", + ["Mischka"] = "#A8A9AD", + ["MistyRose"] = "#FFE4E1", + ["Moccasin"] = "#FFE4B5", + ["Mojo"] = "#954535", + ["MonaLisa"] = "#FF9999", + ["Mongoose"] = "#B38B6D", + ["Montana"] = "#353839", + ["MoodyBlue"] = "#746CC0", + ["MoonYellow"] = "#F5C71A", + ["MossGreen"] = "#ADDFAD", + ["MountainMeadow"] = "#1CAC78", + ["MountainMist"] = "#A19D94", + ["MountbattenPink"] = "#997A8D", + ["Mulberry"] = "#D3419D", + ["Mustard"] = "#FFDB58", + ["Myrtle"] = "#195905", + ["MySin"] = "#FFB347", + ["NavajoWhite"] = "#FFDEAD", + ["Navy"] = "#000080", + ["NavyBlue"] = "#0247FE", + ["NeonCarrot"] = "#FF9933", + ["NeonPink"] = "#FF5CCD", + ["Nepal"] = "#91A3B0", + ["Nero"] = "#141414", + ["NewMidnightBlue"] = "#00009C", + ["Niagara"] = "#3AB09E", + ["NightRider"] = "#3B2F2F", + ["Nobel"] = "#989898", + ["Norway"] = "#A9BA9D", + ["Nugget"] = "#B78727", + ["OceanGreen"] = "#5FA778", + ["Ochre"] = "#CA7309", + ["OldCopper"] = "#6F4E37", + ["OldGold"] = "#CFB53B", + ["OldLace"] = "#FDF5E6", + ["OldLavender"] = "#796878", + ["OldRose"] = "#C32148", + ["Olive"] = "#808000", + ["OliveDrab"] = "#6B8E23", + ["OliveGreen"] = "#B5B35C", + ["Olivetone"] = "#6E6E30", + ["Olivine"] = "#9AB973", + ["Onahau"] = "#C4D8E2", + ["Opal"] = "#A8C3BC", + ["Orange"] = "#FFA500", + ["OrangePeel"] = "#FB9902", + ["OrangeRed"] = "#FF4500", + ["Orchid"] = "#DA70D6", + ["OuterSpace"] = "#2D383A", + ["OutrageousOrange"] = "#FE5A1D", + ["Oxley"] = "#5FA777", + ["PacificBlue"] = "#0088DC", + ["Padua"] = "#80C197", + ["PalatinatePurple"] = "#702963", + ["PaleBrown"] = "#A0785A", + ["PaleChestnut"] = "#DDADAF", + ["PaleCornflowerBlue"] = "#BCD4E6", + ["PaleGoldenrod"] = "#EEE8AA", + ["PaleGreen"] = "#98FB98", + ["PaleMagenta"] = "#F984EF", + ["PalePink"] = "#FADADD", + ["PaleSlate"] = "#C9C0BB", + ["PaleTaupe"] = "#BC987E", + ["PaleTurquoise"] = "#AFEEEE", + ["PaleVioletRed"] = "#DB7093", + ["PalmLeaf"] = "#354230", + ["Panache"] = "#E9FFDB", + ["PapayaWhip"] = "#FFEFD5", + ["ParisDaisy"] = "#FFF44F", + ["Parsley"] = "#306030", + ["PastelGreen"] = "#77DD77", + ["PattensBlue"] = "#DBE9F4", + ["Peach"] = "#FFCBA4", + ["PeachOrange"] = "#FFCC99", + ["PeachPuff"] = "#FFDAB9", + ["PeachYellow"] = "#FADFAD", + ["Pear"] = "#D1E231", + ["PearlLusta"] = "#EAE0C8", + ["Pelorous"] = "#2A8FBD", + ["Perano"] = "#ACACE6", + ["Periwinkle"] = "#C5CBE1", + ["PersianBlue"] = "#2243B6", + ["PersianGreen"] = "#00A693", + ["PersianIndigo"] = "#330066", + ["PersianPink"] = "#F77FBE", + ["PersianRed"] = "#C0362C", + ["PersianRose"] = "#E936A7", + ["Persimmon"] = "#EC5800", + ["Peru"] = "#CD853F", + ["Pesto"] = "#807532", + ["PictonBlue"] = "#6699CC", + ["PigmentGreen"] = "#00AD43", + ["PigPink"] = "#FFDAE9", + ["PineGreen"] = "#01796F", + ["PineTree"] = "#2A2F23", + ["Pink"] = "#FFC0CB", + ["PinkFlare"] = "#BFAFB2", + ["PinkLace"] = "#F0D3DC", + ["PinkSwan"] = "#B3B3B3", + ["Plum"] = "#DDA0DD", + ["Pohutukawa"] = "#660C21", + ["PoloBlue"] = "#779ECB", + ["Pompadour"] = "#811453", + ["Portage"] = "#92A1CF", + ["PotPourri"] = "#F1DDCF", + ["PottersClay"] = "#84563C", + ["PowderBlue"] = "#B0E0E6", + ["Prim"] = "#E4C4CF", + ["PrussianBlue"] = "#003A6C", + ["PsychedelicPurple"] = "#DF00FF", + ["Puce"] = "#CC8899", + ["Pueblo"] = "#6C2E1F", + ["PuertoRico"] = "#43B3AE", + ["Pumpkin"] = "#FF631C", + ["Purple"] = "#800080", + ["PurpleMountainsMajesty"] = "#967BB6", + ["PurpleTaupe"] = "#5D3954", + ["QuarterSpanishWhite"] = "#E6E0D4", + ["Quartz"] = "#DCD0FF", + ["Quincy"] = "#6A5445", + ["RacingGreen"] = "#1A2421", + ["RadicalRed"] = "#FF2052", + ["Rajah"] = "#FBAB60", + ["RawUmber"] = "#7B3F00", + ["RazzleDazzleRose"] = "#FE4EDA", + ["Razzmatazz"] = "#D70A53", + ["Red"] = "#FF0000", + ["RedBerry"] = "#841617", + ["RedDamask"] = "#CB6D51", + ["RedOxide"] = "#630F0F", + ["RedRobin"] = "#804040", + ["RichBlue"] = "#545AA7", + ["Riptide"] = "#8DD9CC", + ["RobinsEggBlue"] = "#00CCCC", + ["RobRoy"] = "#E1A95F", + ["RockSpray"] = "#AB381F", + ["RomanCoffee"] = "#836953", + ["RoseBud"] = "#F6A494", + ["RoseBudCherry"] = "#873260", + ["RoseTaupe"] = "#905D5D", + ["RosyBrown"] = "#BC8F8F", + ["Rouge"] = "#B03060", + ["RoyalBlue"] = "#4169E1", + ["RoyalHeath"] = "#A8516E", + ["RoyalPurple"] = "#663398", + ["Ruby"] = "#D71868", + ["Russet"] = "#80461B", + ["Rust"] = "#C04000", + ["RusticRed"] = "#480607", + ["Saddle"] = "#635147", + ["SaddleBrown"] = "#8B4513", + ["SafetyOrange"] = "#FF6600", + ["Saffron"] = "#F4C430", + ["Sage"] = "#8F9779", + ["Sail"] = "#A1CAF1", + ["Salem"] = "#008543", + ["Salmon"] = "#FA8072", + ["SandyBeach"] = "#FDD5B1", + ["SandyBrown"] = "#F4A460", + ["Sangria"] = "#860111", + ["SanguineBrown"] = "#733635", + ["SanMarino"] = "#5072A7", + ["SanteFe"] = "#AF6E4D", + ["Sapphire"] = "#062A78", + ["Saratoga"] = "#545A2C", + ["Scampi"] = "#666699", + ["Scarlet"] = "#FF2400", + ["ScarletGum"] = "#431C53", + ["SchoolBusYellow"] = "#FFD800", + ["Schooner"] = "#8B8680", + ["ScreaminGreen"] = "#66FF66", + ["Scrub"] = "#3B3C36", + ["SeaBuckthorn"] = "#F99245", + ["SeaGreen"] = "#2E8B57", + ["Seagull"] = "#8CBED6", + ["SealBrown"] = "#3D0C02", + ["Seance"] = "#602F6B", + ["SeaPink"] = "#D7837F", + ["SeaShell"] = "#FFF5EE", + ["Selago"] = "#FAE6FA", + ["SelectiveYellow"] = "#F2B400", + ["SemiSweetChocolate"] = "#6B4423", + ["Sepia"] = "#965A3E", + ["Serenade"] = "#FFE9D1", + ["Shadow"] = "#856D4D", + ["Shakespeare"] = "#72A0C1", + ["Shalimar"] = "#FCFFA4", + ["Shamrock"] = "#44D7A8", + ["ShamrockGreen"] = "#009966", + ["SherpaBlue"] = "#004B49", + ["SherwoodGreen"] = "#1B4D3E", + ["Shilo"] = "#DEA5A4", + ["ShipCove"] = "#778BA5", + ["Shocking"] = "#F19CBB", + ["ShockingPink"] = "#FF1DCE", + ["ShuttleGrey"] = "#54626F", + ["Sidecar"] = "#EEE0B1", + ["Sienna"] = "#A0522D", + ["Silk"] = "#BEA493", + ["Silver"] = "#C0C0C0", + ["SilverChalice"] = "#AFB1AE", + ["SilverTree"] = "#66C992", + ["SkyBlue"] = "#87CEEB", + ["SlateBlue"] = "#6A5ACD", + ["SlateGray"] = "#708090", + ["SlateGrey"] = "#708090", + ["Smalt"] = "#00308F", + ["SmaltBlue"] = "#4A646C", + ["Snow"] = "#FFFAFA", + ["SoftAmber"] = "#D1BEA8", + ["Solitude"] = "#EBECF0", + ["Sorbus"] = "#E9692C", + ["Spectra"] = "#35654D", + ["SpicyMix"] = "#88654E", + ["Spray"] = "#7ED4E6", + ["SpringBud"] = "#96FF00", + ["SpringGreen"] = "#00FF7F", + ["SpringSun"] = "#ECEBBD", + ["SpunPearl"] = "#AAA9AD", + ["Stack"] = "#828E84", + ["SteelBlue"] = "#4682B4", + ["Stiletto"] = "#893F45", + ["Strikemaster"] = "#915C83", + ["StTropaz"] = "#32527B", + ["Studio"] = "#734F96", + ["Sulu"] = "#C9DC87", + ["SummerSky"] = "#21ABCD", + ["Sun"] = "#ED872D", + ["Sundance"] = "#C5B358", + ["Sunflower"] = "#E4D00A", + ["Sunglow"] = "#FFCC33", + ["SunsetOrange"] = "#FD5240", + ["SurfieGreen"] = "#007474", + ["Sushi"] = "#6F9940", + ["SuvaGrey"] = "#8C8C8C", + ["Swamp"] = "#232B2B", + ["SweetCorn"] = "#FDDB6D", + ["SweetPink"] = "#F39998", + ["Tacao"] = "#ECB176", + ["TahitiGold"] = "#EB6123", + ["Tan"] = "#D2B48C", + ["Tangaroa"] = "#001C3D", + ["Tangerine"] = "#E48400", + ["TangerineYellow"] = "#FDCC0D", + ["Tapestry"] = "#B76E79", + ["Taupe"] = "#483C32", + ["TaupeGrey"] = "#8B8589", + ["TawnyPort"] = "#66424D", + ["TaxBreak"] = "#4F666A", + ["TeaGreen"] = "#D0F0C0", + ["Teak"] = "#B08D57", + ["Teal"] = "#008080", + ["TeaRose"] = "#FF85CF", + ["Temptress"] = "#3C1421", + ["Tenne"] = "#C86500", + ["TerraCotta"] = "#E2725B", + ["Thistle"] = "#D8BFD8", + ["TickleMePink"] = "#F56FA1", + ["Tidal"] = "#E8F48C", + ["TitanWhite"] = "#D6CADD", + ["Toast"] = "#A57164", + ["Tomato"] = "#FF6347", + ["TorchRed"] = "#FF033E", + ["ToryBlue"] = "#365194", + ["Tradewind"] = "#6EAEA1", + ["TrendyPink"] = "#856088", + ["TropicalRainForest"] = "#007F66", + ["TrueV"] = "#8B72BE", + ["TulipTree"] = "#E5B73B", + ["Tumbleweed"] = "#DEAA88", + ["Turbo"] = "#FFC324", + ["TurkishRose"] = "#98777B", + ["Turquoise"] = "#40E0D0", + ["TurquoiseBlue"] = "#76D7EA", + ["Tuscany"] = "#AF593E", + ["TwilightBlue"] = "#FDFFF5", + ["Twine"] = "#BA8759", + ["TyrianPurple"] = "#66023C", + ["Ultramarine"] = "#0A1195", + ["UltraPink"] = "#FF6FFF", + ["Valencia"] = "#DE5246", + ["VanCleef"] = "#543D37", + ["VanillaIce"] = "#E5CCC9", + ["VenetianRed"] = "#D1001C", + ["Venus"] = "#8A7F80", + ["Vermilion"] = "#FB4F14", + ["VeryLightGrey"] = "#CFCFCF", + ["VidaLoca"] = "#5E8C31", + ["Viking"] = "#47ABCC", + ["Viola"] = "#B48395", + ["ViolentViolet"] = "#32174D", + ["Violet"] = "#EE82EE", + ["VioletRed"] = "#FF3988", + ["Viridian"] = "#40826D", + ["VistaBlue"] = "#9FE2BF", + ["VividViolet"] = "#7F3E98", + ["WaikawaGrey"] = "#536895", + ["Wasabi"] = "#96A53C", + ["Watercourse"] = "#006A4E", + ["Wedgewood"] = "#436B95", + ["WellRead"] = "#933D41", + ["Wewak"] = "#FF9899", + ["Wheat"] = "#F5DEB3", + ["Whiskey"] = "#D99A6C", + ["WhiskeySour"] = "#D99058", + ["White"] = "#FFFFFF", + ["WhiteSmoke"] = "#F5F5F5", + ["WildRice"] = "#E4D96F", + ["WildSand"] = "#E5E4E2", + ["WildStrawberry"] = "#FC419A", + ["WildWatermelon"] = "#FF5470", + ["WildWillow"] = "#ACBF60", + ["Windsor"] = "#4C2882", + ["Wisteria"] = "#BF94E4", + ["Wistful"] = "#A2A2D0", + ["Yellow"] = "#FFFF00", + ["YellowGreen"] = "#9ACD32", + ["YellowOrange"] = "#FFAE42", + ["YourPink"] = "#F4C2C2", + }; +} From 124eebb57eaf53986caf033bf315df7f648dae4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 20:28:34 +0200 Subject: [PATCH 09/10] Fix Windows PowerShell parity and legacy section edge cases --- Module/Tests/Binary.Compose.Tests.ps1 | 13 +++++- Module/Tests/Import-Module.Tests.ps1 | 22 ++++------ .../Legacy.MessageCard.Compose.Tests.ps1 | 43 +++++++++++++++++++ TeamsX.PowerShell/CmdletNewTeamsMessage.cs | 9 ++-- TeamsX.PowerShell/CmdletNewTeamsSection.cs | 43 +++++++++++++++++-- TeamsX/TeamsColorUtility.cs | 2 +- TeamsX/WebhookMessageRenderer.cs | 2 +- 7 files changed, 109 insertions(+), 25 deletions(-) diff --git a/Module/Tests/Binary.Compose.Tests.ps1 b/Module/Tests/Binary.Compose.Tests.ps1 index ae6c821..3ab6099 100644 --- a/Module/Tests/Binary.Compose.Tests.ps1 +++ b/Module/Tests/Binary.Compose.Tests.ps1 @@ -89,9 +89,9 @@ Describe 'TeamsX binary cmdlets through PSTeams' { $chat = New-TeamsGraphTarget -ChatId '19:testchat@thread.v2' -AccessToken 'token-2' -DisplayName 'Ops chat' -GraphBaseUri 'https://graph.example.test/' $channel.DeliveryMethod.ToString() | Should -Be 'GraphChannelMessage' - $channel.TargetUri.ToString() | Should -Be 'https://graph.example.test/v1.0/teams/team-42/channels/channel-99/messages' + $channel.TargetUri.AbsoluteUri | Should -Be 'https://graph.example.test/v1.0/teams/team-42/channels/channel-99/messages' $chat.DeliveryMethod.ToString() | Should -Be 'GraphChatMessage' - $chat.TargetUri.ToString() | Should -Be 'https://graph.example.test/v1.0/chats/19%3Atestchat%40thread.v2/messages' + $chat.TargetUri.AbsoluteUri | Should -Be 'https://graph.example.test/v1.0/chats/19%3Atestchat%40thread.v2/messages' } It 'creates graph targets backed by environment variables and secure strings' { @@ -146,6 +146,15 @@ Describe 'TeamsX binary cmdlets through PSTeams' { $json | Should -Match '"text":"Pipeline 42"' } + It 'treats null sections input as empty when building a typed Teams message' { + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force + + $sections = $null + { $message = New-TeamsMessage -Title 'Build failed' -Sections $sections } | Should -Not -Throw + $message.Sections.Count | Should -Be 0 + $message.UseConnectorCardFormat | Should -BeFalse + } + It 'renders typed wrapper-card objects through ConvertTo-TeamsJson' { Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force diff --git a/Module/Tests/Import-Module.Tests.ps1 b/Module/Tests/Import-Module.Tests.ps1 index bca5a4e..ff574a8 100644 --- a/Module/Tests/Import-Module.Tests.ps1 +++ b/Module/Tests/Import-Module.Tests.ps1 @@ -123,28 +123,22 @@ Describe 'PSTeams module migration shell' { } It 'preserves every legacy command name on main' { - $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") - $currentScript = @' -Import-Module '{CURRENT_PATH}' -Force -Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object | ConvertTo-Json -'@.Replace('{CURRENT_PATH}', $currentPath) + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force | Out-Null - $legacyNames = @(Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyCommands.json') -Raw | ConvertFrom-Json) - $currentNames = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) + $legacyNamesJson = Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyCommands.json') -Raw + $legacyNames = @(ConvertFrom-Json $legacyNamesJson | ForEach-Object { $_ }) + $currentNames = @(Get-Command -Module PSTeams | Select-Object -ExpandProperty Name | Sort-Object) $missing = @($legacyNames | Where-Object { $_ -notin $currentNames } | Sort-Object) $missing | Should -BeNullOrEmpty } It 'preserves every legacy alias target on main' { - $currentPath = (Resolve-Path "$PSScriptRoot\..\PSTeams\PSTeams.psd1").Path.Replace("'", "''") - $currentScript = @' -Import-Module '{CURRENT_PATH}' -Force -Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object | ConvertTo-Json -'@.Replace('{CURRENT_PATH}', $currentPath) + Import-Module "$PSScriptRoot\..\PSTeams\PSTeams.psd1" -Force | Out-Null - $legacyAliases = @(Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyAliases.json') -Raw | ConvertFrom-Json) - $currentAliases = @(pwsh -NoProfile -Command $currentScript | ConvertFrom-Json) + $legacyAliasesJson = Get-Content (Join-Path -Path $baselinePath -ChildPath 'LegacyAliases.json') -Raw + $legacyAliases = @(ConvertFrom-Json $legacyAliasesJson | ForEach-Object { $_ }) + $currentAliases = @(Get-Alias | Where-Object Source -eq 'PSTeams' | ForEach-Object { '{0}=>{1}' -f $_.Name, $_.Definition } | Sort-Object) $missing = @($legacyAliases | Where-Object { $_ -notin $currentAliases } | Sort-Object) $missing | Should -BeNullOrEmpty diff --git a/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 index 9779263..d639f72 100644 --- a/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 +++ b/Module/Tests/Legacy.MessageCard.Compose.Tests.ps1 @@ -36,6 +36,49 @@ Describe 'Legacy connector-card migration cmdlets' { $section.Facts.Count | Should -Be 1 } + It 'preserves action-card link targets and date-input subtypes from legacy dictionaries' { + $section = New-TeamsSection { + [ordered]@{ + type = 'button' + name = 'Add comment' + '@type' = 'ActionCard' + Inputs = @( + [ordered]@{ + '@type' = 'TextInput' + } + ) + actions = @( + [ordered]@{ + '@type' = 'HttpPOST' + target = 'https://example.test/comment' + } + ) + } + [ordered]@{ + type = 'button' + name = 'Choose date' + '@type' = 'ActionCard' + Inputs = @( + [ordered]@{ + '@type' = 'DateInput' + } + ) + actions = @( + [ordered]@{ + '@type' = 'HttpPOST' + target = 'https://example.test/date' + } + ) + } + } + + $section.Buttons.Count | Should -Be 2 + $section.Buttons[0].ButtonType.ToString() | Should -Be 'TextInput' + $section.Buttons[0].Link | Should -Be 'https://example.test/comment' + $section.Buttons[1].ButtonType.ToString() | Should -Be 'DateInput' + $section.Buttons[1].Link | Should -Be 'https://example.test/date' + } + It 'creates legacy list facts from migrated cmdlets' { $fact = New-TeamsList -Name 'Checklist' { New-TeamsListItem -Text 'Top level' -Level 0 diff --git a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs index 39125d9..f017d73 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsMessage.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsMessage.cs @@ -32,6 +32,7 @@ public sealed class CmdletNewTeamsMessage : PSCmdlet { public SwitchParameter UseConnectorCardFormat { get; set; } protected override void ProcessRecord() { + var sections = Sections ?? Array.Empty(); var request = new TeamsMessageRequest { Title = Title, Text = Text, @@ -39,10 +40,10 @@ protected override void ProcessRecord() { AdaptiveCard = AdaptiveCard, ThemeColor = ResolveThemeColor(), HideOriginalBody = HideOriginalBody.IsPresent, - UseConnectorCardFormat = ShouldUseConnectorCardFormat() + UseConnectorCardFormat = ShouldUseConnectorCardFormat(sections) }; - foreach (var section in Sections) { + foreach (var section in sections) { if (section is not null) { request.Sections.Add(section); } @@ -59,13 +60,13 @@ protected override void ProcessRecord() { return TeamsColorUtility.NormalizeToHex(ThemeColor); } - private bool ShouldUseConnectorCardFormat() { + private bool ShouldUseConnectorCardFormat(TeamsMessageSection[] sections) { if (UseConnectorCardFormat.IsPresent) { return true; } return AdaptiveCard is null && - (Sections.Length > 0 || + (sections.Length > 0 || !string.IsNullOrWhiteSpace(ThemeColor) || HideOriginalBody.IsPresent); } diff --git a/TeamsX.PowerShell/CmdletNewTeamsSection.cs b/TeamsX.PowerShell/CmdletNewTeamsSection.cs index 4f78e6f..4b8ada8 100644 --- a/TeamsX.PowerShell/CmdletNewTeamsSection.cs +++ b/TeamsX.PowerShell/CmdletNewTeamsSection.cs @@ -123,7 +123,7 @@ private void ApplySectionItem(TeamsMessageSection section, object? input) { section.Buttons.Add(new TeamsMessageButton { Name = GetDictionaryString(dictionary, "name") ?? GetDictionaryString(dictionary, "Name"), Link = GetButtonLink(dictionary), - ButtonType = ParseButtonType(GetDictionaryString(dictionary, "@type")) + ButtonType = ParseButtonType(dictionary) }); return; case "fact": @@ -224,18 +224,55 @@ private static void AddMessageImage(TeamsMessageSection section, TeamsMessageIma } } + if (dictionary.Contains("actions")) { + var actionsValue = dictionary["actions"]; + if (actionsValue is IEnumerable actions && actionsValue is not string) { + foreach (var entry in actions) { + if (entry is IDictionary actionDictionary) { + var nestedTarget = GetButtonLink(actionDictionary); + if (!string.IsNullOrWhiteSpace(nestedTarget)) { + return nestedTarget; + } + } + } + } + } + return null; } - private static TeamsMessageButtonType ParseButtonType(string? payloadType) { + private static TeamsMessageButtonType ParseButtonType(IDictionary dictionary) { + var payloadType = GetDictionaryString(dictionary, "@type"); return payloadType switch { - "ActionCard" => TeamsMessageButtonType.TextInput, + "ActionCard" => GetActionCardButtonType(dictionary), "HttpPOST" => TeamsMessageButtonType.HttpPost, "OpenURI" => TeamsMessageButtonType.OpenUri, _ => TeamsMessageButtonType.ViewAction }; } + private static TeamsMessageButtonType GetActionCardButtonType(IDictionary dictionary) { + if (dictionary.Contains("Inputs")) { + var inputs = dictionary["Inputs"]; + if (inputs is IEnumerable enumerable && inputs is not string) { + foreach (var entry in enumerable) { + if (entry is IDictionary inputDictionary) { + var inputType = GetDictionaryString(inputDictionary, "@type"); + if (string.Equals(inputType, "DateInput", StringComparison.OrdinalIgnoreCase)) { + return TeamsMessageButtonType.DateInput; + } + + if (string.Equals(inputType, "TextInput", StringComparison.OrdinalIgnoreCase)) { + return TeamsMessageButtonType.TextInput; + } + } + } + } + } + + return TeamsMessageButtonType.TextInput; + } + private static void ValidateActivityImagePath(FileInfo path) { TeamsPowerShellImageSupport.ValidateImageFile( path, diff --git a/TeamsX/TeamsColorUtility.cs b/TeamsX/TeamsColorUtility.cs index abef763..004aade 100644 --- a/TeamsX/TeamsColorUtility.cs +++ b/TeamsX/TeamsColorUtility.cs @@ -10,7 +10,7 @@ public static class TeamsColorUtility { return null; } - var candidate = color.Trim(); + var candidate = color!.Trim(); if (candidate.StartsWith("#", StringComparison.Ordinal)) { return IsHexColor(candidate) ? candidate.ToUpperInvariant() diff --git a/TeamsX/WebhookMessageRenderer.cs b/TeamsX/WebhookMessageRenderer.cs index 4bfb9a3..a577d1a 100644 --- a/TeamsX/WebhookMessageRenderer.cs +++ b/TeamsX/WebhookMessageRenderer.cs @@ -106,7 +106,7 @@ private static string RenderAdaptiveCardMessage(TeamsMessageRequest request) { if (section.HeroImages.Count > 0) { var fragments = new List(section.HeroImages); if (!string.IsNullOrWhiteSpace(text)) { - fragments.Add(text); + fragments.Add(text!); } text = string.Join(" ", fragments); From 494c537421c3e160db5484339c8169b657c25270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 23 Apr 2026 20:54:43 +0200 Subject: [PATCH 10/10] Fix legacy list-card dictionary handling --- Module/Tests/WrapperCard.Compose.Tests.ps1 | 19 +++++++++++++++++++ TeamsX.PowerShell/CmdletNewCardList.cs | 8 ++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Module/Tests/WrapperCard.Compose.Tests.ps1 b/Module/Tests/WrapperCard.Compose.Tests.ps1 index bb04387..fede982 100644 --- a/Module/Tests/WrapperCard.Compose.Tests.ps1 +++ b/Module/Tests/WrapperCard.Compose.Tests.ps1 @@ -74,6 +74,25 @@ Describe 'Wrapper-card migration cmdlets' { $body | Should -Match '"title":"Show"' } + It 'prefers legacy list-item dictionaries over generic button fallback' { + $body = New-CardList -Title 'Card Title' { + [ordered]@{ + type = 'file' + title = 'Report' + subtitle = 'teams > new > design' + tap = [ordered]@{ + type = 'openUrl' + value = 'editOnline https://contoso.example/report.xlsx' + } + } + } + + $body | Should -Match '"items":\[' + $body | Should -Match '"type":"file"' + $body | Should -Match '"value":"editOnline https://contoso.example/report.xlsx"' + $body | Should -Not -Match '"buttons":\[\{"type":"file"' + } + It 'supports ListCard sending in WhatIf mode' { $result = New-CardList -Title 'Card Title' -Uri 'https://example.test/webhook' -WhatIf { New-CardListItem -Type File -Title 'Report' -SubTitle 'teams > new > design' -TapType OpenUrl -TapValue 'https://contoso.example/report.xlsx' -TapAction editOnline diff --git a/TeamsX.PowerShell/CmdletNewCardList.cs b/TeamsX.PowerShell/CmdletNewCardList.cs index 86cbdb6..129f37b 100644 --- a/TeamsX.PowerShell/CmdletNewCardList.cs +++ b/TeamsX.PowerShell/CmdletNewCardList.cs @@ -62,13 +62,13 @@ private void ApplyItem(TeamsListCard card, object? value) { } if (value is IDictionary dictionary) { - if (CmdletNewHeroCard.TryCreateCardButton(dictionary, out var fallbackButton)) { - ApplyItem(card, fallbackButton); + if (TryCreateListItem(dictionary, out var fallbackItem)) { + ApplyItem(card, fallbackItem); return; } - if (TryCreateListItem(dictionary, out var fallbackItem)) { - ApplyItem(card, fallbackItem); + if (CmdletNewHeroCard.TryCreateCardButton(dictionary, out var fallbackButton)) { + ApplyItem(card, fallbackButton); } } }