diff --git a/.github/workflows/publicrecordingbot_build-and-test.yml b/.github/workflows/publicrecordingbot_build-and-test.yml new file mode 100644 index 000000000..f4c43d0d2 --- /dev/null +++ b/.github/workflows/publicrecordingbot_build-and-test.yml @@ -0,0 +1,149 @@ +name: Recording Bot Build And Test +run-name: Pull Request "${{github.event.pull_request.title}}" build and test + +on: + pull_request: + branches: + - main + - master + paths: + - Samples/PublicSamples/RecordingBot/** + +jobs: + + check-recording-bot-changes: + runs-on: ubuntu-latest + outputs: + build: ${{ steps.changes.outputs.build }} + deploy: ${{ steps.changes.outputs.deploy }} + docs: ${{ steps.changes.outputs.docs }} + scripts: ${{ steps.changes.outputs.scripts }} + src: ${{ steps.changes.outputs.src }} + steps: + - uses: actions/checkout@v4 + - shell: pwsh + id: changes + run: | + # Diff latest commit with latest main commit for Recording Bot + git fetch + git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} -- Samples/PublicSamples/RecordingBot/ + $diff = git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} -- Samples/PublicSamples/RecordingBot/ + + # Check if a file has changed (added, modified, deleted) + $BuildDiff = $diff | Where-Object { $_ -match '^Samples/PublicSamples/RecordingBot/build/' } + $DeployDiff = $diff | Where-Object { $_ -match '^Samples/PublicSamples/RecordingBot/deploy/' } + $DocsDiff = $diff | Where-Object { $_ -match '^Samples/PublicSamples/RecordingBot/docs/' -or $_ -match '.md$' } + $ScriptsDiff = $diff | Where-Object { $_ -match '^Samples/PublicSamples/RecordingBot/scripts/' } + $SrcDiff = $diff | Where-Object { $_ -match '^Samples/PublicSamples/RecordingBot/src/' } + + $HasBuildDiff = $BuildDiff.Length -gt 0 + $HasDeployDiff = $DeployDiff.Length -gt 0 + $HasDocsDiff = $DocsDiff.Length -gt 0 + $HasScriptsDiff = $ScriptsDiff.Length -gt 0 + $HasSrcDiff = $SrcDiff.Length -gt 0 + + # Set the outputs + echo "build=$HasBuildDiff" >> $env:GITHUB_OUTPUT + echo "deploy=$HasDeployDiff" >> $env:GITHUB_OUTPUT + echo "docs=$HasDocsDiff" >> $env:GITHUB_OUTPUT + echo "scripts=$HasScriptsDiff" >> $env:GITHUB_OUTPUT + echo "src=$HasSrcDiff" >> $env:GITHUB_OUTPUT + + dotnet-build-and-test: + runs-on: windows-2022 + needs: check-recording-bot-changes + if: needs.check-recording-bot-changes.outputs.src == 'True' + + defaults: + run: + working-directory: Samples/PublicSamples/RecordingBot/src + + steps: + - uses: actions/checkout@v4 + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.0.x" + + - name: Rename .env-template + run: | + Rename-Item "RecordingBot.Tests\.env-template" -NewName ".env" + Rename-Item "RecordingBot.Console\.env-template" -NewName ".env" + + - name: Build project + run: dotnet build + + - name: Test project + run: dotnet test + + docker-build: + runs-on: windows-2022 + needs: check-recording-bot-changes + if: needs.check-recording-bot-changes.outputs.build == 'True' + + defaults: + run: + working-directory: Samples/PublicSamples/RecordingBot/ + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker Image + shell: bash + run: docker build -f ./build/Dockerfile . -t "teams-recording-bot:${GITHUB_SHA}" + + chart-build-and-test: + runs-on: ubuntu-latest + needs: check-recording-bot-changes + if: needs.check-recording-bot-changes.outputs.build == 'True' || needs.check-recording-bot-changes.outputs.deploy == 'True' || needs.check-recording-bot-changes.outputs.scripts == 'True' || needs.check-recording-bot-changes.outputs.src == 'True' + + defaults: + run: + working-directory: Samples/PublicSamples/RecordingBot/deploy + + steps: + - uses: actions/checkout@v4 + - run: | + git fetch + git branch -a + + - name: Lint Helm Chart + working-directory: Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot + if: needs.check-recording-bot-changes.outputs.deploy == 'True' + run: helm lint + + - name: Check App Version Change + if: needs.check-recording-bot-changes.outputs.build == 'True' || needs.check-recording-bot-changes.outputs.scripts == 'True' || needs.check-recording-bot-changes.outputs.src == 'True' + shell: bash + run: | + oldVersion=$(MSYS_NO_PATHCONV=1 git show remotes/origin/$GITHUB_BASE_REF:Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.yaml | sed -n "s/^appVersion: \([0-9\.]*\)$/\1/p") + echo "Previous app Version: $oldVersion" + [ -z "$oldVersion" ] && exit 1 + + newVersion=$(cat teams-recording-bot/Chart.yaml | sed -n "s/^appVersion: \([0-9\.]*\)$/\1/p") + echo "New app Version: $newVersion" + [ -z "$newVersion" ] && exit 1 + + echo "Check if app Version was updated" + [ "$newVersion" = "$oldVersion" ] && exit 1 + newerVersion=$(echo -e "$oldVersion\n$newVersion" | sort -V | tail -1) + [ "$newerVersion" = "$newVersion" ] || exit 1 + echo "Success app Version was updated!" + + - name: Check Version Change + shell: bash + run: | + oldVersion=$(MSYS_NO_PATHCONV=1 git show remotes/origin/$GITHUB_BASE_REF:Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.yaml | sed -n "s/^version: \([0-9\.]*\)$/\1/p") + echo "Previous Version: $oldVersion" + [ -z "$oldVersion" ] && exit 1 + + newVersion=$(cat teams-recording-bot/Chart.yaml | sed -n "s/^version: \([0-9\.]*\)$/\1/p") + echo "New Version: $newVersion" + [ -z "$newVersion" ] && exit 1 + + echo "Check if Version was updated" + [ "$newVersion" = "$oldVersion" ] && exit 1 + newerVersion=$(echo -e "$oldVersion\n$newVersion" | sort -V | tail -1) + [ "$newerVersion" = "$newVersion" ] || exit 1 + echo "Success Version was updated!" \ No newline at end of file diff --git a/.github/workflows/publicrecordingbot_codeql.yml b/.github/workflows/publicrecordingbot_codeql.yml new file mode 100644 index 000000000..2df45c09b --- /dev/null +++ b/.github/workflows/publicrecordingbot_codeql.yml @@ -0,0 +1,75 @@ +name: "Recording Bot CodeQL" + +on: + push: + branches: + - main + - master + paths: + - Samples/PublicSamples/RecordingBot/** + pull_request: + branches: + - main + - master + paths: + - Samples/PublicSamples/RecordingBot/** + schedule: + - cron: "24 5 * * 5" + +jobs: + analyze: + name: Analyze + runs-on: windows-2022 + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + defaults: + run: + working-directory: Samples/PublicSamples/RecordingBot/src + + strategy: + fail-fast: false + matrix: + language: [csharp] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.0.x" + + - name: Rename .env-template + run: | + Rename-Item "RecordingBot.Tests\.env-template" -NewName ".env" + Rename-Item "RecordingBot.Console\.env-template" -NewName ".env" + + - name: Build project + run: dotnet build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publicrecordingbot_release-docker-image.yml b/.github/workflows/publicrecordingbot_release-docker-image.yml new file mode 100644 index 000000000..11156cfed --- /dev/null +++ b/.github/workflows/publicrecordingbot_release-docker-image.yml @@ -0,0 +1,42 @@ +name: Recording Bot Release Docker Image + +on: + push: + branches: + - main + - master + paths: + - Samples/PublicSamples/RecordingBot/** + - .github/workflows/publicrecordingbot_release-docker-image.yml + +jobs: + build-push-cr: + runs-on: windows-2022 + + permissions: + packages: write + + defaults: + run: + working-directory: Samples/PublicSamples/RecordingBot + + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Tag docker image + shell: bash + run: | + docker build -f ./build/Dockerfile . -t ${{ vars.CR_NAMESPACE_REPOSITORY }}:latest + + - name: Push docker image to CR + shell: bash + run: | + docker push ${{ vars.CR_NAMESPACE_REPOSITORY }}:latest \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/.dockerignore b/Samples/PublicSamples/RecordingBot/.dockerignore new file mode 100644 index 000000000..98a93c3d9 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/.dockerignore @@ -0,0 +1,65 @@ +_ReSharper.*/ +.git + +**/Obj/ +**/obj/ +**/bin/ +**/Bin/ +.vs/ +*.xap +*.user +/TestResults +*.vspscc +*.vssscc +*.suo +*.cache +*.docstates +_ReSharper.* +*.csproj.user +*[Rr]e[Ss]harper.user +_ReSharper.*/ +packages/* +artifacts/* +msbuild.log +PublishProfiles/ +*.psess +*.vsp +*.pidb +*.userprefs +*DS_Store +*.ncrunchsolution +*.log +*.vspx +/.symbols +nuget.exe +*net45.csproj +*k10.csproj +App_Data/ +bower_components +node_modules +*.sln.ide +*.ng.ts +*.sln.ide +.build/ +.testpublish/ +launchSettings.json + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +**/.vs/* +**/*.wav + +**/.env-template +**/.env + +samples diff --git a/Samples/PublicSamples/RecordingBot/.gitignore b/Samples/PublicSamples/RecordingBot/.gitignore new file mode 100644 index 000000000..58a6a4ce2 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/.gitignore @@ -0,0 +1,407 @@ +## Much of this is a duplicate of top-level gitignore, but allows +## to copy this sample to a new repository without having to copy +## the top-level gitignore. + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +# Project specific files that should be ignored +*.env +deploy/teams-recording-bot/charts/* +src/RecordingBot.Console/cache/* \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/LICENSE b/Samples/PublicSamples/RecordingBot/LICENSE new file mode 100644 index 000000000..1684a6207 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/LICENSE @@ -0,0 +1,45 @@ + MIT License + + Copyright (c) LM IT Services AG. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +--- + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/Samples/PublicSamples/RecordingBot/README.md b/Samples/PublicSamples/RecordingBot/README.md new file mode 100644 index 000000000..88249fd15 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/README.md @@ -0,0 +1,63 @@ +> [!NOTE] +> Public Samples are provided by developers from the Microsoft Graph community. +> Public Samples are not official Microsoft Communication samples, and not supported by the Microsoft Communication engineering team. It is recommended that you contact the sample owner before using code from Public Samples in production systems. + +--- + +**Title:** +RecordingBot + +**Description:** +A compliance recording bot sample running on AKS (Azure Kubernetes Service). + +**Authors:** +Owning organization: LM IT Services AG ([@LM-Development](https://github.com/LM-Development) on GitHub) +Lead maintainer: [@InDieTasten](https://github.com/InDieTasten) + +**Fork for issues and contributions:** +[LM-Development/aks-sample](https://github.com/LM-Development/aks-sample) + +# Introduction + +This sample allows you to build, deploy and test a compliance recording bot running on Azure Kubernetes Service and is currently the only sample demonstrating a basis for zero-downtime deployments and horizontal scaling ability. + +The unique purpose of this sample is to demonstrate how to run production grade bots. The bot implementation can easily be changed to fit other use cases other than compliance recording. + +## Contents + +| File/folder | Description | +|-------------------|--------------------------------------------| +| `build` | Contains `Dockerfile` to containerise app. | +| `deploy` | Helm chart and other manifests to deploy. | +| `docs` | Markdown files with steps and guides. | +| `scripts` | Helpful scripts for running project. | +| `src` | Sample source code. | +| `.gitignore` | Define what to ignore at commit time. | +| `README.md` | This README file. | +| `LICENSE` | The license for the sample. | + +## Getting Started + +The easiest way to grasp the basics surrounding compliance bots is to read up on the following documentation topics: + +- [High Level Overview over the Infrastructure for the Recording Bot](./docs/explanations/recording-bot-overview.md) +- [Bot Service - Entra Id and Microsoft Graph API Calling Permissions](./docs/explanations/recording-bot-permission.md) +- [Compliance Recording Policies](./docs/explanations/recording-bot-policy.md) + +### Deploy + +[Deploy the recording bot sample to AKS with the tutorial](./docs/tutorials/deploy-tutorial.md), to get your own recording bot up and running. + +### Test + +1. [Assign a policy to a Teams user](./docs/guides/policy.md) +2. Sign into Teams with user under compliance recording policy +3. Start a meeting +4. Verify existence of recording banner in meeting + +> [!NOTE] +> Propagation of Compliance Recording Policy assignments can take up to 24 hours. + +## Questions and Support + +Please open an issue in the [issue tracker](https://github.com/LM-Development/aks-sample/issues) of the source repository. diff --git a/Samples/PublicSamples/RecordingBot/build/Dockerfile b/Samples/PublicSamples/RecordingBot/build/Dockerfile new file mode 100644 index 000000000..c85db292d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/build/Dockerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS build + +ARG CallSignalingPort=9441 +ARG CallSignalingPort2=9442 +ARG InstanceInternalPort=8445 + +COPY /src /src + +WORKDIR /src/RecordingBot.Console +RUN dotnet build RecordingBot.Console.csproj --arch x64 --self-contained --configuration Release --output C:\app + + +FROM mcr.microsoft.com/windows/server:ltsc2022 +SHELL ["powershell", "-Command"] + +ADD https://aka.ms/vs/17/release/vc_redist.x64.exe /bot/VC_redist.x64.exe + +COPY /scripts/entrypoint.cmd /bot +COPY /scripts/halt_termination.ps1 /bot +COPY --from=build /app /bot + +WORKDIR /bot + +RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \ + [System.Net.ServicePointManager]::SecurityProtocol = \ + [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; \ + iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) + +RUN choco install openssl.light -y + +EXPOSE $InstanceInternalPort +EXPOSE $CallSignalingPort +EXPOSE $CallSignalingPort2 + +ENTRYPOINT [ "entrypoint.cmd" ] diff --git a/Samples/PublicSamples/RecordingBot/build/certs.bat b/Samples/PublicSamples/RecordingBot/build/certs.bat new file mode 100644 index 000000000..d7d8128cc --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/build/certs.bat @@ -0,0 +1,22 @@ +REM Set up Environment Variables +./set_env.cmd .env + +set /A CallSignalingPort2 = %AzureSettings__CallSignalingPort% + 1 + +REM Deleting bindings +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% +netsh http delete urlacl url=https://+:%AzureSettings__CallSignalingPort%/ +netsh http delete urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ +netsh http delete urlacl url=http://+:%CallSignalingPort2%/ + +REM Add URLACL bindings +netsh http add urlacl url=https://+:%AzureSettings__CallSignalingPort%/ sddl=D:(A;;GX;;;S-1-1-0) +netsh http add urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ sddl=D:(A;;GX;;;S-1-1-0) +netsh http add urlacl url=http://+:%CallSignalingPort2%/ sddl=D:(A;;GX;;;S-1-1-0) + +REM ensure the app id matches the GUID in AssemblyInfo.cs +REM Ensure the certhash matches the certificate + +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% certhash=YOUR_CERT_THUMBPRINT appid={aeeb866d-e17b-406f-9385-32273d2f8691} +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% certhash=YOUR_CERT_THUMBPRINT appid={aeeb866d-e17b-406f-9385-32273d2f8691} diff --git a/Samples/PublicSamples/RecordingBot/deploy/cert-manager/install.bat b/Samples/PublicSamples/RecordingBot/deploy/cert-manager/install.bat new file mode 100644 index 000000000..da1edc651 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/cert-manager/install.bat @@ -0,0 +1,22 @@ +@echo off + +echo Updating helm repo +helm repo add jetstack https://charts.jetstack.io +helm repo update + +echo Installing cert-manager +helm upgrade ^ + cert-manager jetstack/cert-manager ^ + --namespace cert-manager ^ + --create-namespace ^ + --version v1.13.3 ^ + --install ^ + --set nodeSelector."kubernetes\.io/os"=linux ^ + --set webhook.nodeSelector."kubernetes\.io/os"=linux ^ + --set cainjector.nodeSelector."kubernetes\.io/os"=linux ^ + --set installCRDs=true + +echo Waiting for cert-manager to be ready +kubectl wait pod -n cert-manager --for condition=ready --timeout=60s --all + +pause \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/.helmignore b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/.helmignore new file mode 100644 index 000000000..4828917b5 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +.gitkeep diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.lock b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.lock new file mode 100644 index 000000000..8952b78e3 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: ingress-nginx + repository: https://kubernetes.github.io/ingress-nginx + version: 4.8.3 +digest: sha256:7dafa2cf6d937c80fb3cd1791ad242b055f9dc67931e216b6da22350c534177b +generated: "2024-01-08T12:22:54.332827+01:00" diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.yaml new file mode 100644 index 000000000..c02703c01 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/Chart.yaml @@ -0,0 +1,31 @@ +apiVersion: v2 +name: teams-recording-bot +description: Helm chart for deploying teams-recording-bot +icon: "" + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 1.4.1 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +appVersion: 1.3.1 + +dependencies: + - name: ingress-nginx + version: 4.8.3 + repository: https://kubernetes.github.io/ingress-nginx + alias: ingress-nginx + condition: ingress-nginx.enabled diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/README.md b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/README.md new file mode 100644 index 000000000..0ce9d689c --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/README.md @@ -0,0 +1,37 @@ +# teams-recording-bot + +Full setup guide can be found [here](../../docs/deploy/aks.md). + +## Configuration + +The following table lists the configurable parameters of the teams-recording-bot and their default calues. + +Parameter | Description | Default +--- | --- | --- +`host` | Used by the bot and ingress. Specifies where Teams is sending the media and notifications to, as well as instructing `cert-manager` the domain to generate the certificate for. This value is required for the chart to deploy. | `null` +`override.name` | If not set, the default name of `teams-recording-bot` will be used to deploy the chart. | `""` +`override.namespace` | If not set, the default namespace the chart will be deployed into will be `teams-recording-bot`. | `""` +`scale.maxReplicaCount` | Used to deploy a number of services, adds port mappings to the `ConfigMap` and opens additional ports on the `LoadBalancer` in preparation for additional bots to be deployed. | `3` +`scale.replicaCount` | The number of bots to deploy. | `3` +`image.domain` | Where your recording bot container lives (for example `acr.azurecr.io`). This value is required for the chart to deploy. | `null` +`image.pullPolicy` | Sets the pull policy. By default the image will only be pulled if it is not present locally. | `IfNotPresent` +`image.tag` | Override the image tag you want to pull and deploy. If not set, by default, the `.Chart.AppVersion` will be used instead. | `""` +`ingress.tls.secretName`| The secret name `cert-manager` generates after creating the certificate. | `ingress-tls` +`autoscaling.enabled` | Flag for enabling and disabling `replicas` in the `StatefulSet`. | `false` +`internal.port` | HTTP port the bot listens to to receive HTTP based events (like joining calls and notifications) from Teams. | `9441` +`internal.media` | The internal TCP port the bot listens to and receives media from. | `8445` +`public.media` | The port is used to send media traffic from Teams to the bot. `public.media` is added to the `LoadBalancer` with each bot receiving their own public facing TCP port. | `28550` +`public.ip` | This value should be the static IP address you have reserved in your Azure subscription and is what your `host` is pointing to. This value is required for the chart to deploy. | `null` +`node.target` | Name of the node to bound the `StatefulSet` to. By default, the `StatefulSet` expects there to be a Windows node deployed in your node pool with the name `scale`. | `scale` +`terminationGracePeriod` | When scaling down, `terminationGracePeriod` allows pods with ongoing calls to remain active until either the call ends or the `terminationGracePeriod` expires. This number is in seconds and by default is set to `54000` seconds (15 hours). This should give the pod enough time to wait for the call to end before the pod is allowed to terminate and remove itself. | `54000` +`container.env.azureSettings.captureEvents` | Flag to indicate if events should be saved or not. If set to `false` the no events will be saved. | `false` +`container.env.azureSettings.eventsFolder` | Folder where events (like HTTP events) are saved. Folder will be created is it does not exist. Folder can be located under `%TEMP%\teams-recording-bot\`. Events are separated in their own folders using the `callId`. Events are saved as `BSON` files and is used to help generate test data which can be archived as `zip` files and reused in unit tests. | `events` +`container.env.azureSettings.mediaFolder` | Folder where audio archives will be saved. If the folder does not exist, it will be created. Folder can be located under `%TEMP%\teams-recording-bot\`. Media for each call will be saved in their own folder using the `callId`. | `archive` +`container.env.azureSettings.eventhubKey` | API Key of your Azure Event Hub. This is required if you want to send events using Azure Event Hub. If not set, no events will be sent. | `""` +`container.env.azureSettings.eventhubName` | The name of your Azure Event Hub. | `recordingbotevents` +`container.env.azureSettings.eventhubRegion` | Azure region your Azure Event Hub is deployed to. | `""` +`container.env.azureSettings.isStereo` | Flag to indicate the output audio file should be stereo or mono. If set to `false`, the output audio file saved to disk will be mono while if set to `true`, the output audio file saved to disk will be stereo. | `false` +`container.env.azureSettings.wavSampleRate` | When omitted, audio file will be saved a sample rate of 16000 Hz, but this env variable can be used to re-sample the audio stream into a different sample bit rate, i.e. 44.1 KHz for mp3 files. | `0` +`container.env.azureSettings.wavQuality` | From 0 to 100%, when omitted, by default is 100%. | `100` +`container.port` | Internal port the bot listens to for HTTP requests. | `9441` +`resources` | Used to set resource limits on the bot deployed. | `{}` diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/_helpers.tpl b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/_helpers.tpl new file mode 100644 index 000000000..b8989f11a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/_helpers.tpl @@ -0,0 +1,135 @@ +{{/* Default deployment name */}} +{{- define "fullName" -}} + {{- default $.Release.Name $.Values.global.override.name -}} +{{- end -}} + +{{/* Nginx fullName */}} +{{/*We have to differentiate the context this is called in from sub chart or from parent chart*/}} +{{- define "ingress-nginx.fullname" -}} + {{- if $.Values.controller -}} + {{- if $.Values.fullnameOverride -}} + {{- $.Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} + {{- else -}} + {{- default (printf "%s-ingress-nginx" (include "fullName" .)) $.Values.nameOverride -}} + {{- end -}} + {{- else -}} + {{- if (index $.Values "ingress-nginx" "fullnameOverride") -}} + {{- (index $.Values "ingress-nginx" "fullnameOverride") -}} + {{- else -}} + {{- default (printf "%s-ingress-nginx" (include "fullName" .)) (index $.Values "ingress-nginx" "nameOverride") -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- define "ingress-nginx.instance" -}} + {{- default $.Release.Name (index $.Values "ingress-nginx" "instance") -}} +{{- end -}} + +{{- define "ingress-nginx.name" -}} + {{- if $.Values.controller -}} + {{- default (include "ingress-nginx.fullname" .) .Values.nameOverride | trunc 63 | trimSuffix "-" -}} + {{- else -}} + {{- default (include "ingress-nginx.fullname" .) (index $.Values "ingress-nginx" "nameOverride") | trunc 63 | trimSuffix "-" -}} + {{- end -}} +{{- end -}} + +{{/*We have to differentiate the context this is called in from sub chart or from parent chart*/}} +{{- define "ingress-nginx.controller.fullname" -}} + {{- if $.Values.controller -}} + {{- printf "%s-%s" (include "ingress-nginx.fullname" .) $.Values.controller.name | trunc 63 | trimSuffix "-" -}} + {{- else -}} + {{- printf "%s-%s" (include "ingress-nginx.fullname" .) (index $.Values "ingress-nginx" "controller" "name") | trunc 63 | trimSuffix "-" -}} + {{- end -}} +{{- end -}} + +{{/* Default namespace */}} +{{- define "namespace" -}} + {{- default $.Release.Namespace $.Values.global.override.namespace -}} +{{- end -}} + +{{/* Nginx namespace */}} +{{/*We have to differentiate the context this is called in from sub chart or from parent chart*/}} +{{- define "ingress-nginx.namespace" -}} + {{- if $.Values.controller -}} + {{- default (include "namespace" .) $.Values.namespaceOverride -}} + {{- else -}} + {{- default (include "namespace" .) (index $.Values "ingress-nginx" "namespaceOverride") -}} + {{- end -}} +{{- end -}} + +{{/* Check replicaCount is less than maxReplicaCount */}} +{{- define "maxCount" -}} + {{- if lt (int $.Values.scale.maxReplicaCount) 1 -}} + {{- fail "scale.maxReplicaCount cannot be less than 1" -}} + {{- end -}} + {{- if gt (int $.Values.scale.replicaCount) (int .Values.scale.maxReplicaCount) -}} + {{- fail "scale.replicaCount cannot be greater than scale.maxReplicaCount" -}} + {{- else -}} + {{- printf "%d" (int $.Values.scale.maxReplicaCount) -}} + {{- end -}} +{{- end -}} + +{{/* Check if issuer email is set */}} +{{- define "cluster-issuer.email" -}} + {{- if eq $.Values.ingress.tls.email "YOUR_EMAIL" -}} + {{- fail "You need to specify a ingress tls email for lets encrypt" -}} + {{- else if $.Values.ingress.tls.email -}} + {{- printf "%s" $.Values.ingress.tls.email -}} + {{- else -}} + {{- fail "You need to specify a ingress tls email for lets encrypt" -}} + {{- end -}} +{{- end -}} + +{{/*Define ingress-tls secret name*/}} +{{- define "ingress.tls.secretName" -}} + {{- default (printf "ingress-tls-%s" (include "fullName" .)) $.Values.ingress.tls.secretName -}} +{{- end -}} + +{{/*Define ingress path*/}} +{{- define "ingress.path" -}} + {{- printf "/%s" (trimPrefix "/" $.Values.ingress.path) -}} +{{- end -}} + +{{/*Define ingress path*/}} +{{- define "ingress.path.withTrailingSlash" -}} + {{- printf "%s/" (trimSuffix "/" (include "ingress.path" .)) -}} +{{- end -}} + + +{{/* Check if host is set */}} +{{- define "hostName" -}} + {{- if .Values.host -}} + {{- printf "%s" $.Values.host -}} + {{- else -}} + {{- fail "You need to specify a host" -}} + {{- end -}} +{{- end -}} + +{{/* Check if image.domain is set */}} +{{- define "imageDomain" -}} + {{- if $.Values.image.domain -}} + {{- printf "%s" $.Values.image.domain -}} + {{- else -}} + {{- fail "You need to specify image.domain" -}} + {{- end -}} +{{- end -}} + +{{/* Check if public.ip is set */}} +{{- define "publicIP" -}} + {{- if $.Values.public.ip -}} + {{- printf "%s" $.Values.public.ip -}} + {{- else -}} + {{- fail "You need to specify public.ip" -}} + {{- end -}} +{{- end -}} + +{{/*Update nginx params with generated tcp-config-map*/}} +{{/*because it is called in the context of the subchart we can only use global values or the subcharts values*/}} +{{- define "ingress-nginx.params" -}} +- /nginx-ingress-controller +- --election-id={{ include "ingress-nginx.controller.electionID" . }} +- --controller-class=k8s.io/{{ include "ingress-nginx.fullname" .}} +- --ingress-class={{ include "ingress-nginx.fullname" .}} +- --configmap=$(POD_NAMESPACE)/{{ include "ingress-nginx.controller.fullname" . }} +- --tcp-services-configmap={{ include "ingress-nginx.namespace" . }}/{{ include "fullName" . }}-tcp-services +{{- end -}} \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/cluster-issuer.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/cluster-issuer.yaml new file mode 100644 index 000000000..5af521fb6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/cluster-issuer.yaml @@ -0,0 +1,22 @@ +{{- $fullName := include "fullName" . -}} +{{- $email := include "cluster-issuer.email" . -}} +{{- $namespace := include "namespace" . -}} +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: {{ $fullName }}-issuer + namespace: {{ $namespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: {{ $email }} + privateKeySecretRef: + name: {{ $fullName }}-issuer + solvers: + - http01: + ingress: + ingressClassName: {{ include "ingress-nginx.fullname" . }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/configmap.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/configmap.yaml new file mode 100644 index 000000000..710bfcf9f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/configmap.yaml @@ -0,0 +1,17 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +{{- $nginxNamespace := include "ingress-nginx.namespace" . -}} +{{- $maxCount := include "maxCount" . -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $fullName }}-tcp-services + namespace: {{ $nginxNamespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +data: +{{- range $i, $e := until (int $maxCount) }} + {{ (int $.Values.public.media) | add $i | quote | nindent 2 }}: {{ $namespace }}/{{ $fullName }}-{{ $i }}:{{ $.Values.internal.media }} +{{- end }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/deployment.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/deployment.yaml new file mode 100644 index 000000000..fefe6f126 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/deployment.yaml @@ -0,0 +1,133 @@ +{{- $fullName := include "fullName" . -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $fullName }} + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +spec: +{{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.scale.replicaCount }} +{{- end }} + serviceName: {{ $fullName }} + podManagementPolicy: "Parallel" + template: + metadata: + name: {{ $fullName }} + labels: + app: {{ $fullName }} + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - {{ .Values.node.targetOS }} + - key: kubernetes.io/arch + operator: In + values: + - {{ .Values.node.targetArch }} + - key: kubernetes.azure.com/os-sku + operator: In + values: + - {{ .Values.node.targetSku }} + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: "app" + operator: In + values: + - {{ $fullName }} + topologyKey: "kubernetes.io/hostname" + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriod }} + containers: + - name: recording-bot + image: "{{ .Values.image.registry }}/{{ .Values.image.name }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + lifecycle: + preStop: + exec: + command: + - powershell + - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine; + - .\halt_termination.ps1 + ports: + - containerPort: {{ .Values.internal.port }} + - containerPort: {{ .Values.internal.media }} + volumeMounts: + - mountPath: "C:/certs/" + name: certificate + readOnly: true + env: + - name: AzureSettings__BotName + valueFrom: + secretKeyRef: + name: {{ .Values.secrets.resourceName }} + key: botName + - name: AzureSettings__AadAppId + valueFrom: + secretKeyRef: + name: {{ .Values.secrets.resourceName }} + key: applicationId + - name: AzureSettings__AadAppSecret + valueFrom: + secretKeyRef: + name: {{ .Values.secrets.resourceName }} + key: applicationSecret + - name: AzureSettings__ServicePath + value: {{ include "ingress.path.withTrailingSlash" . }} + - name: AzureSettings__ServiceDnsName + value: {{ include "hostName" . }} + - name: AzureSettings__InstancePublicPort + value: "{{ .Values.public.media }}" + - name: AzureSettings__InstanceInternalPort + value: "{{ .Values.internal.media }}" + - name: AzureSettings__CallSignalingPort + value: "{{ .Values.internal.port }}" + - name: AzureSettings__CallSignalingPublicPort + value: "{{ .Values.public.https }}" + - name: AzureSettings__PlaceCallEndpointUrl + value: https://graph.microsoft.com/v1.0 + - name: AzureSettings__CaptureEvents + value: "{{ .Values.container.env.azureSetting.captureEvents }}" + - name: AzureSettings__EventsFolder + value: "{{ .Values.container.env.azureSetting.eventsFolder }}" + - name: AzureSettings__MediaFolder + value: "{{ .Values.container.env.azureSetting.mediaFolder }}" + - name: AzureSettings__TopicKey + value: "{{ .Values.container.env.azureSetting.eventhubKey }}" + - name: AzureSettings__TopicName + value: "{{ .Values.container.env.azureSetting.eventhubName }}" + - name: AzureSettings__RegionName + value: "{{ .Values.container.env.azureSetting.eventhubRegion }}" + - name: AzureSettings__IsStereo + value: "{{ .Values.container.env.azureSetting.isStereo }}" + - name: AzureSettings__WAVSampleRate + value: "{{ .Values.container.env.azureSetting.wavSampleRate }}" + - name: AzureSettings__WAVQuality + value: "{{ .Values.container.env.azureSetting.wavQuality }}" + - name: AzureSettings__PodName + valueFrom: + fieldRef: + fieldPath: metadata.name + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: certificate + secret: + secretName: {{ include "ingress.tls.secretName" . }} + imagePullSecrets: + - name: acr-secret + selector: + matchLabels: + app: {{ $fullName }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/external.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/external.yaml new file mode 100644 index 000000000..60d4f7318 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/external.yaml @@ -0,0 +1,37 @@ +{{- $maxCount := include "maxCount" . -}} +{{- $namespace := include "ingress-nginx.namespace" . -}} +{{- $fullName := include "ingress-nginx.fullname" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-external + namespace: {{ $namespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +spec: + type: LoadBalancer + externalTrafficPolicy: Cluster + loadBalancerIP: {{ include "publicIP" . }} + ports: +{{- if (index $.Values "ingress-nginx" "enabled") }} + - name: http + port: {{ $.Values.public.http }} + targetPort: http + protocol: TCP + - name: https + port: {{ $.Values.public.https }} + targetPort: https + protocol: TCP +{{- end }} +{{- range $i, $e := until (int $maxCount) }} + - name: {{ $.Values.public.media | add $i }}-tcp + port: {{ $.Values.public.media | add $i }} + targetPort: {{ $.Values.public.media | add $i }} + protocol: TCP +{{- end }} + selector: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: {{ include "ingress-nginx.instance" . }} + app.kubernetes.io/name: {{ include "ingress-nginx.name" .}} \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/headless.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/headless.yaml new file mode 100644 index 000000000..1c5bd0a55 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/headless.yaml @@ -0,0 +1,20 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ $namespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +spec: + clusterIP: None + publishNotReadyAddresses: true + ports: + - name: public + port: 443 + targetPort: {{ .Values.internal.port }} + selector: + app: {{ $fullName }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingress.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingress.yaml new file mode 100644 index 000000000..30cfd12fb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ $namespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} + annotations: + cert-manager.io/cluster-issuer: {{ $fullName }}-issuer + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + nginx.ingress.kubernetes.io/server-snippet: | + location ~* ^({{ include "ingress.path.withTrailingSlash" . }})(?[0-9]*)/api/calling/notification { + proxy_pass https://{{ $fullName }}-$instance.{{ $fullName }}.{{ $namespace }}.svc.cluster.local:{{ .Values.internal.port }}; + } +spec: + ingressClassName: {{ include "ingress-nginx.fullname" .}} + tls: + - hosts: + - {{ include "hostName" . }} + secretName: {{ include "ingress.tls.secretName" . }} + rules: + - host: {{ include "hostName" . }} + http: + paths: + - path: {{ include "ingress.path" . }} + pathType: Prefix + backend: + service: + name: {{ $fullName }}-routing + port: + number: 443 \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingressclass.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingressclass.yaml new file mode 100644 index 000000000..1e1d35621 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/ingressclass.yaml @@ -0,0 +1,16 @@ +{{- $fullName := include "ingress-nginx.fullname" . -}} +{{- if (index $.Values "ingress-nginx" "enabled") -}} +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} + app.kubernetes.io/name: {{ include "ingress-nginx.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: controller + name: {{ $fullName }} +spec: + controller: "k8s.io/{{$fullName}}" +{{- end -}} \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/routing.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/routing.yaml new file mode 100644 index 000000000..ed8904d7c --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/routing.yaml @@ -0,0 +1,19 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-routing + namespace: {{ $namespace }} + labels: + helmVersion: {{ .Chart.Version }} + helmAppVersion: {{ .Chart.AppVersion }} + helmName: {{ .Chart.Name }} +spec: + clusterIP: None + ports: + - name: public + port: 443 + targetPort: {{ $.Values.internal.port }} + selector: + app: {{ $fullName }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/service.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/service.yaml new file mode 100644 index 000000000..84b9687a8 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/templates/service.yaml @@ -0,0 +1,24 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +{{- $maxCount := include "maxCount" . -}} +{{- range $i, $e := until (int $maxCount) }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-{{ $i }} + namespace: {{ $namespace }} + labels: + helmVersion: {{ $.Chart.Version }} + helmAppVersion: {{ $.Chart.AppVersion }} + helmName: {{ $.Chart.Name }} +spec: + type: ClusterIP + publishNotReadyAddresses: true + ports: + - name: media + port: {{ $.Values.internal.media }} + targetPort: {{ $.Values.internal.media }} + selector: + statefulset.kubernetes.io/pod-name: {{ $fullName }}-{{ $i }} +--- +{{- end }} diff --git a/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/values.yaml b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/values.yaml new file mode 100644 index 000000000..0383d6d7a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/deploy/teams-recording-bot/values.yaml @@ -0,0 +1,83 @@ +global: + override: + name: "" + namespace: "" + +scale: + maxReplicaCount: 3 + replicaCount: 3 + +host: null + +image: + registry: null + name: null + tag: null + pullPolicy: IfNotPresent + +ingress: + path: / + tls: + email: YOUR_EMAIL + +secrets: + resourceName: bot-application-secrets + +autoscaling: + enabled: false + +internal: + port: 9441 + media: 8445 + +public: + media: 28550 + https: 443 + http: 80 + ip: null + +node: + targetOS: windows + targetArch: amd64 + targetSku: Windows2022 + +terminationGracePeriod: 54000 + +container: + env: + azureSetting: + captureEvents: false + eventsFolder: events + mediaFolder: archive + eventhubKey: "" + eventhubName: recordingbotevents + eventhubRegion: "" + isStereo: false + wavSampleRate: 0 # when it is 0, the default sample rate will be set per the stereo flag setting above + wavQuality: 100 + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +ingress-nginx: + enabled: true + controller: + ingressClassRessource: + enabled: false + allowSnippetAnnotations: true + replicaCount: 1 + nodeSelector: + "kubernetes.io/os": linux + service: + enabled: false + admissionWebhooks: + enabled: false \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/docs/Environment Setup errors.md b/Samples/PublicSamples/RecordingBot/docs/Environment Setup errors.md new file mode 100644 index 000000000..43b1bc539 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/Environment Setup errors.md @@ -0,0 +1,48 @@ +# Setting Local Environment Gotchas + +## README.md + +### Update configuration value + +1. When running `runngrok.bat` file in Windows, the following error pops up on the screen: + +```cmd +'REM' is not recognized as an internal or external command, +operable program or batch file. +``` + +To fix that, I had to apply `dos2unix` command on this file. + +2. After building the solution in Visual Studio 2019 and running in the debug mode, the RecordingBot.Console app exited with the Exception "Media platform failed to initialize" in Microsoft.Group.Communications.Calls.Media.dll in Program.cs at the line #72: + +```c# +_botService.Initialize(); +``` + +3. It seems like the step #5 on the line 175 in the Readme.md document is redundant when running docker in the command like. The script `.\entrypoint.cmd` is executed when the docker image starts running. + +## Bot.md + +### Bot Registration + +During the Web platform configuration in the AD, the following are missing: + +- The example of the redirect URI +- The instructions for either the implicit grant flow must be enabled or left untouched. + +## Policy.md + +### Create an Application Instance + +1. When executing the following command: `Import-PSSession $sfbSession`, the following error occurred: +![Screenshot1](./images/screenshot1.png) + + Adding the following step before running the last command has solved this issue: +`Set-ExecutionPolicy RemoteSigned`. I've also updated the original document. + +2. When adding new application instance, if the same user identity has already been previously associated with another application, the `New-CsOnlineApplicationInstance` command will fail. I've modified the document to anticipate this possible scenario. + +## Source Files + +1. There could be the mismatch between the hardcoded `DefaultPort` and the custom provided `AzureSettings__InstancePublicPort` in the `.env` file. It seems like the former is redundant. I've fixed it by adding a new Env var `AzureSettings__CallSignalingPort` and replacing all occurances of `DefaultPort` with `CallSignalingPort` in the `AzureSettings.cs` file. In addition, the `AzureSettings__InstancePublicPort` was repurpsed to hold the TCP forwarding port number. + diff --git a/Samples/PublicSamples/RecordingBot/docs/Learnings.md b/Samples/PublicSamples/RecordingBot/docs/Learnings.md new file mode 100644 index 000000000..0d60ecd76 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/Learnings.md @@ -0,0 +1,15 @@ +# Project Learnings + +## Learning areas + +- Recording options available are limited for custom or bespoke recording, Policy Based recording is the only practically available option without getting a legal exemption. Policy based recording has a number of challenges unique to its working. + - Requiring a policy to be attached to a user who will be recorded. Every call that user joins in Teams is then recorded by default. In some usage scenarios this is not desirable. + - Policies can only be applied to members of your AAD subscription. Guests cannot have a recording policy, it has to be applied in their home Active Directory. + - If you need the capability to have a user sometimes recorded and sometimes not, that user either needs two identities or a capability to instruct the bot not to record. This is possible but adds complexity to the overall architecture of a solution and requires a centralised mechanism to query the status and request status changes in the bot. +- Scaling of the recording bot is non-trivial + - Due to the bot being stateful and potentially participating in long running transactions (calls can be up to 24 Hours in duration), it is non-trivial to upgrade or scale down the number of bots deployed. A bot has to wait until all calls it is interacting with are complete. There are samples of configuration for Kubernetes in this repository that demonstrate how to do this. +- Dependencies on Media Libraries that are Windows only + - A full installation of Windows is required for the bot to work. This creates large containers on build (includes the full Windows installation). It also requires consideration and management of the node types in Kubernetes as there will be at least one Windows pod instance in the cluster and therefore Windows nodes. There is nothing difficult about it, it just requires thinking about. + - Development is done with the .NET Framework (it cannot be done using .NET Core). +- Micro-services architectures that depend on the bot require careful planning + - Handing off processing to other services in latency critical applications, while in a call is not simple. This is not specific to the bot or its deployment environment but a consequence of working with audio or any near real-time source and expected output. When working with speech it does not make sense to break segments at arbitrary points. This will significantly impact the accuracy of things like speech recognition. The implications are you either have to re-assemble coherent segments of speech in a downstream service (and then determining what is a coherent segment becomes challenging and dealing with latency) or manage the service from the bot itself. This is easier to develop but results in the bot becoming monolithic. Services that are not latency dependent (e.g. speech recognition after the event) would not be impacted by this. diff --git a/Samples/PublicSamples/RecordingBot/docs/Overview.md b/Samples/PublicSamples/RecordingBot/docs/Overview.md new file mode 100644 index 000000000..21ac8c690 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/Overview.md @@ -0,0 +1,89 @@ +# Media Bot Overview + +This document covers a brief introduction to why the bot was built as well as what is required to make it work. + +## Background + +The bot was developed in response to a customer need to be able to record the multi-channel interactions on a Teams call. Recording a call is only one of the things that can be done with the media stream delivered to the bot. Therefore the bot as delivered in this repository is designed to be easy to extend and add functionality for various use cases. Please be aware that it is against the terms of use to record without informing all participants that the call is being recorded. + +***Examples of the types of use cases to which a bot can be applied include:*** + +- Responding to meeting process or content in real-time (e.g. a digital assistant delivering information to particiapants in a call) + +- Responding to the content of the call such as using Speech to Text and working with the text result for example a digital assistant + +- Enhancing call capabilities such as doing DTMF tone collection + +- Injecting content into calls, both audio and video + +- Converting the voice stream to text in near real-time + +- Passing the media to another service to do something like emotion detection in a call. + +## Origins of this bot + +The bot was original built to support call recording. There are a number of recording options available to users of Teams shown below. + +![Teams recording types taxonomy](https://docs.microsoft.com/en-us/microsoftteams/media/recording-taxonomy.png) + +[See original link here](https://docs.microsoft.com/en-us/microsoftteams/teams-recording-policy#teams-interaction-recording-overview) +The details of how each recording approach is appropriate is contained in the article linked here. + +The most flexible option for an end consumer is the policy based Organisational Recording option, also referred to as Compliance Recording. It is 'triggered' by a policy, attached to a user, that will include a bot into a meeting. The bot can then interact with the media stream. Policy based recording also makes it possible to display the recording banner as required in the terms of service for using the API with Teams. + +## How it works + +The diagram illustrates the main components required for the bot: + +* The Teams client is incidental in the operation of the bot. It is used to start and join calls by users. It is also possible to commence and join calls using the [Graph API]([Working with the communications API in Microsoft Graph - Microsoft Graph v1.0 | Microsoft Docs](https://docs.microsoft.com/en-us/graph/api/resources/communications-api-overview?view=graph-rest-1.0)). + +* A channel is registered in the [Azure Portal ]([Azure Bot Service | Microsoft Azure](https://azure.microsoft.com/en-us/services/bot-service/)). This channel contains configuration and permission information for the bot application. + +* The Teams central infrastructure is where all Teams interactions occur. It is shown only for context, there is nothing to be done with the Teams infrastructure itself. + +* Users of Teams are managed from the [Admin Portal](https://admin.microsoft.com/Adminportal/Home) + +* The channel registration contains an HTTPS url that will be the signalling endpoint which receives all notifications from Teams. This is what the bot listens to in order to join calls and recieve notifications from Teams + +* The bot itself is a .NET Framework application (C#) developed using the [Graph Communications SDK and API]([Graph Communications Media SDK](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/calls_media/index.html)) + +A bot is enabled for a call by means of a Bot Channel Registration in the Azure Portal. This lets Teams know that there is a channel that should be included and how to find it. [The registration process](https://docs.microsoft.com/en-us/microsoftteams/platform/bots/calls-and-meetings/registering-calling-bot) includes nominating an HTTP webhook URL. This is the endpoint that will be called by Teams when a call starts. This registration process includes granting graph api permissions. + +![Highlevel overview of the bot deployed](images/HighLevelOverview.png) + +Each user that is covered by the recording requirement has a recording policy attached to them. [The online documentation covers the process of creating and attaching the policy](https://docs.microsoft.com/en-us/microsoftteams/teams-recording-policy#compliance-recording-policy-assignment-and-provisioning). There are also [instructions in this repository](./Documentation Outline TOC.md) that take you through the steps of doing this. The policy is what results in the media stream for a nominated user being sent to the bot (using the channel registered above). + +The bot itself is developed in C# and has to run on a full Windows machine. This is due to [the requirements of the media library SDK](https://www.nuget.org/packages/Microsoft.Graph.Communications.Calls.Media/) which require .Net framework to use. + +The bot will most likely be deployed to a cloud based server, likely in a container. This however makes development and debugging cumbersome. + +For local development purposes it is possible to use Ngrok to act as the signalling and TCP media traffic endpoitn and have it redirected to your local machine. There are notes about how to setup this development environment in the repository. + +## Delivered assets + +The main asset delivered in this repository is a media receiving bot and associated documentation. The bot is joined into meetings via a Compliance Policy attached to a user. Once connected to a meeting/call a media stream is delivered to the bot and the bot can then do various things with that stream for example: + +- Persisting the stream and associated metadata to act as a meeting recorded + +- Use the content of the stream to connect to other services (like speech to text for near real-time transcription) + +The bot as delivered here is intended to be a sample that can be a starting point for further development. To this end it is intended to be a base starting point and a 'skeleton' that can be built out on. It is also intended to be as flexible as possible so that it can be deployed in different ways. + +The repository includes instructions on how to deploy the bot into Kubernetes. This is to support scaling requirements for when a lot of calls are being connected to. The deployment documentation also deals with how to scale the bot. It is a stateful application with potentially long running processes (meetings and calls can be up to 24 hours in duration) therefore scaling the number of bots down must consider ongoing calls and letting them complete. + +## What is in the repository? + +The repository contains the following items/code/information + +- Code for a general purpose media end point bot ('the bot') + +- Documentation on how to develop on, and, extend the bot + +- Guidance on how to deploy the bot for production use (in Kubernetes) + +## Things to be aware of with compliance based inclusion of the bot + +* Guest users cannot have a recording policy attached to them. The recording policy would have to be setup in their home Active Directory. +* Polocies can take time to propogate to users. Generally it is pretty quick but it can take hours for a policy to take effect. +* Every session that the user with the policy attends will by default be recorded. +* If the bot attached to the call crashes or leaves the meeting for whatever reason, the user with the attached policy relevant to that bot will also be removed from the meeting. diff --git a/Samples/PublicSamples/RecordingBot/docs/debug.md b/Samples/PublicSamples/RecordingBot/docs/debug.md new file mode 100644 index 000000000..8a02fc5f6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/debug.md @@ -0,0 +1,42 @@ + +## A list of common issues when debugging locally + +### Local Console app + +You are running the bot within visual studio (as a console app) and it starts, runs and closes unexpectedly early. + +**Possible Solution:** + +Run Visual Studio with Administrative privileges (run in Admin mode - indicator in top right of Visual Studio will confirm) + +### Call Status + +If your Bot Call status is always Establishing and its never Established + +The messaginb bot is unable to commmunicate with the media server + +**Possible solutions:** + +1. Check SSL Certificates +2. Rerun certs.bat +3. Ngrok check that running and is redirecting correctly (no errors or missing responses) +4. Ngrok check local firewall (incoming and outgoing) +5. Check Reserved TCP Address in Ngrok site is setup + +(To be confirmed: there has been some mentions of only 0 and 1 subdomains in Ngrok being useful e.g. +1.tcp.ngrok.io:27188) + +### Cannot add participant + +If your media service cannot connect to your local media processor and returns a *MediaControllerConversationAddParticipantFailed* + +**Possible solution:** + +1. Confirm that Ngrok is running locally +2. Confirm that your ports match the port Ngrok opened up for you (.env file) - `AzureSettings__InstancePublicPort=RESERVED_PORT` +3. your `CName` points to the right instance of the Ngrok tcp proxy. + +### Other things to try + +Another common issue is not using the right certificate for your bot. +Confirm the certificate is in the correct place, has not expired, the thumbprint is correct and the the certs.bat rules have been run. diff --git a/Samples/PublicSamples/RecordingBot/docs/deploy/aks.md b/Samples/PublicSamples/RecordingBot/docs/deploy/aks.md new file mode 100644 index 000000000..925374df9 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/deploy/aks.md @@ -0,0 +1,520 @@ +# Azure Kubernetes Services + +The following documents the steps required to deploy the bot in Azure Kubernetes Services (AKS). The solution can run in a Kubernetes cluster with Windows VMs hosted elsewhere, but this document specifically outlines a deployment in AKS. + +## TOC + +1. [Setup](#Setup) + - [Create an AKS cluster](#create-an-aks-cluster) + - [Create and setup ACR](#create-and-setup-acr) + - [Install cert-manager and nginx-ingress](#install-cert-manager-and-nginx-ingress) + - [Set up DNS](#set-up-dns) + - [Create Bot Channels Registration](#create-bot-channels-registration) +2. [Build and push Docker image](#build-and-push-docker-image) +3. [Deploy bot to AKS](#deploy-bot-to-aks) +4. [Concept: How bot works in Kubernetes](#concept:-how-bot-works-in-kubernetes) +5. [Validate deployment](#validate-deployment) +6. [Testing](#testing) +7. [Upgrading](#upgrading) +8. [Scaling](#scaling) +9. [Updating Docker Image](#updating-docker-image) +10. [Uninstalling](#uninstalling) + +## Setup + +### Create an AKS cluster + +Before getting started, you need an AKS cluster deployed to Azure. For more detailed steps, see the AKS docs for [deploying an AKS cluster](https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough-portal). Keep the following in mind when creating the cluster: + +- You will need a Windows VM Scale Set with more than 2 vCPU. If you deploy the bot to a VM that does not, the bot will not run. `Standard_DS3_v2` should be enough to get the bot running. +- By default, the bot deploys to a node pool called `scale`. If you call your node pool something else, note down the name for future use. +- By default, the AKS cluster comes with a static IP address. Note down this IP address for future use. + - If you do not have a static IP address, you can [deploy a static IP address](https://docs.microsoft.com/en-us/cli/azure/network/public-ip?view=azure-cli-latest#az-network-public-ip-create) with the `Standard` SKU in the **resource group your AKS deployment generated** (see [Create a static IP address](https://docs.microsoft.com/en-us/azure/aks/static-ip#create-a-static-ip-address)). This static IP needs to be associated to the Load Balancer created by AKS. + To create a new IP address and then associate it with the AKS Load Balancer, use the following Azure CLI commands: + + ```powershell + az network public-ip create ` + --resource-group ` + --name ` + --sku Standard ` + --allocation-method static + ``` + + Note down the public IP address, as shown in the following condensed example output: + + ```json + { + "publicIp": { + ... + "ipAddress": "40.121.183.52", + ... + } + } + ``` + +After creating the cluster, use the `az aks get-credentials` command to download the credentials for the Kubernetes CLI to use: + +```powershell +az aks get-credentials --resource-group --name +``` + +For more details, see the AKS docs for [connecting to the cluster](https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough#connect-to-the-cluster). + +### Create and setup ACR + +To store the bot Docker image, you need an Azure Container Registry (ACR). For more detailed steps, see the ACR docs for [creating a container registry](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-get-started-portal). + +Make sure you also integrate the ACR with AKS. For more detailed steps, see the AKS docs for [configuring ACR integration for existing AKS clusters](https://docs.microsoft.com/en-us/azure/aks/cluster-container-registry-integration#configure-acr-integration-for-existing-aks-clusters). + +### Install cert-manager and nginx-ingress + +In your cluster, you need to install `cert-manager` (which will manage TLS certificates for you) and `nginx-ingress`. + +1. Modify [`cluster-issuer.yaml`](../../deploy/cluster-issuer.yaml) by adding a contact [email](../../deploy/cluster-issuer.yaml#L8). +2. Run the [`cert-manager.bat`](../../deploy/cert-manager.bat) script. +3. Run the [`ingress-nginx.bat`](../../deploy/ingress-nginx.bat) script. + +>Note: After executing the last script `ingress-nginx.bat` and checking the status of the deployed kubernetes pods, you may see that the pod deployed into `ingress-nginx` namespace has either the `Error` or `CrashLoopBackOff` status. Don't worry, it is expected, because there's no default backend installed and the load balancer which will be installed in a later stage doesn't yet exist. Eventually, once you have deployed the bot, that `nginx` pod will work as expected. + +### Set up DNS + +You also need a custom domain that points to your AKS cluster. You can do this by creating a DNS A record pointing to the static IP address of your cluster. For example, you might end up with `subdomain.domain.com` pointing to `12.12.123.123`. + +### Create Bot Channels Registration + +If you haven't already done so, follow the instructions in [bot.md](../setup/bot.md) to register your bot in Azure. For the calling webhook, point it to the `/api/calling` endpoint of your domain (for example, `https://subdomain.domain.com/api/calling`). If you had already created a Bot Channels Registration for local development, be sure to update the calling webhook. + +## Build and push Docker image + +Next, you need to build the Docker image of the bot. If you followed the [main README steps for running in Docker](/README.md#Docker), you may have already built the image. Otherwise, run the following command from the root of the repo: + +```powershell +docker build ` + --build-arg CallSignalingPort= ` + --build-arg CallSignalingPort2= ` + --build-arg InstanceInternalPort= ` + -f ./build/Dockerfile . ` + -t [TAG] +``` + +>where: +[TAG]: youracrname.azurecr.io/teams-recording-bot:[image-version] + +The tag provided to `-t` above follows the format expected by `deployment.yaml`. If you use a different tag, be sure to modify `deployment.yaml` accordingly. + +Once the image is built, push the image to your ACR by first logging into ACR: + +```powershell +az acr login --name youracrname +``` + +and then pushing the image: + +```powershell +docker push youracrname.azurecr.io/teams-recording-bot:1.0.0 +``` + +For more details, see the ACR docs on [pushing and pulling images](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-get-started-docker-cli). + +## Deploy bot to AKS + +Create the namespace that your bot will deploy into, and create a secret that contains values required to run the bot: + +```powershell +kubectl create ns teams-recording-bot + +kubectl create secret generic bot-application-secrets ` + --namespace teams-recording-bot ` + --from-literal=applicationId='BOT_ID' ` + --from-literal=applicationSecret='BOT_SECRET' ` + --from-literal=botName='BOT_NAME' +``` + +**Note**: Replace `BOT_ID`, `BOT_SECRET` and `BOT_NAME` with values from your bot registration (see [bot.md](../setup/bot.md)). + +Now you can deploy the bot into this namespace. From the root of this repository, run the following command to install the bot in your AKS cluster: + +```powershell +helm install teams-recording-bot ./deploy/teams-recording-bot ` + --namespace teams-recording-bot ` + --create-namespace ` + --set host="HOST" ` + --set public.ip=STATIC_IP_ADDRESS ` + --set image.domain=YOUR_ACR_DOMAIN +``` + +**Note**: + +* `HOST` should be your domain (e.g. `subdomain.domain.com`). +* `STATIC_IP_ADDRESS` should be the static IP address associated with your AKS cluster's load balancer. +* `YOUR_ACR_DOMAIN` should be the container registry to which you pushed the Docker image (e.g. `youracrname.azurecr.io`). +* By default the bot deploys to a node pool with the name `scale`. If you did not call your node pool `scale`, you will need to override `node.target` using `--set node.target=NAME_OF_NODEPOOL`. + +## Concept: How Bot works in Kubernetes + +This section details on how the bot works in Kubernetes, what is deployed and how to safely scale. + +![k8s architecture diagram](../images/k8s.png) + +### What is deployed + +Deployed | What it is responsible for +----------------- | -------------------------- +Ingress | This is used to route TCP traffic to the pods. Traffic is round-robin to the next available pod. +Headless Service | Provides the ability to direct traffic to a specific pod in the StatefulSet. +ConfigMap | TCP port mapping used to map public ports on the external load balancer to an a specific pod ClusterIP service. +LoadBalancer | External facing. Has ports opened for both HTTPS and TCP traffic. +StatefulSet | The bot container. +ClusterIP Service | TCP media traffic. Sits in front of the StatefulSet and routes media to the pods. + +### How it ties together + +The following is a breakdown of each component and how they join together. + +#### External Load Balance + +Using `.Values.public.media` as the starting point, helm iterates `.Values.scale.maxReplicaCount` number of times, and exposes `.Values.public.media` + current iteration to the internet. This is so each pod deployed in the StatefulSet has its own public TCP port for Teams to send media to. + +Port 443 is exposed for standard HTTPS traffic to route to each pod. + +This external load balancer is used by Ingress-Nginx and is called `ingress-nginx-controller`. If you are installing Ingress-Nginx using Helm, it is important that it does not also deploy an external load balancer by using `--set controller.service.enabled=false` so that Ingress-Nginx uses the load balancer that's deployed and configured by the `teams-recording-bot` Helm chart. + +#### Ingress + +Once ingress is deployed, it is responsible for routing HTTPS traffic to pods in the StatefulSet. Incoming traffic signalling a bot to join a meeting is round-robin to the next available pod in the StatefulSet using the `teams-recording-bot-routing` headless service. + +Once a bot answers the request and joins the meeting, the bot then gives Teams a special URL to send notifications to. This special URL is parsed in the ingress using NGINX server-snippets and sends traffic directly to the targeted pod, bypassing the round-robin. + +The following is an example of how this is done: + +```yaml +nginx.ingress.kubernetes.io/server-snippet: | + location ~* ^/(?[0-9]*)/api/calling/notification { + proxy_pass https://{{ $fullName }}-$instance.{{ $fullName }}.{{ $namespace }}.svc.cluster.local:{{ .Values.internal.port }}; + } +``` + +* Example request: `https://bot.com/0/api/calling/notification` +* Using regex, we parse `instance` which correlates to the pod's [ordinal index](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#ordinal-index). In the example above, `instance` would equal `0`. + * If this regex is not satisfied, traffic is not intercepted meaning it is able to be round-robin to the next available pod. +* Using `proxy_pass`, we're able to redirect the traffic to the specific pod using the internal address which is provided using the [headless service](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-network-id). + * `{{ $fullName }}` comes from the name of the Helm chart. In our case that would be `teams-recording-bot` and is rendered when deployed using Helm. + * `{{ .Values.internal.port }}` unless specified, defaults to `9441`. + * In our example, the `proxy_pass` URL would be: `https://teams-recording-bot-0.teams-recording-bot.teams-recording-bot.svc.cluster.local:9441`. + +#### Headless Services + +There are two different headless services deployed. These headless services are more or less identical but are used for different scenarios. + +- `teams-recording-bot-routing` - responsible for routing the initial call to an available pod, instructing the bot to join a call. +- `teams-recording-bot` - responsible for routing updates to specific pod in the StatefulSet based on the incoming request. + +**Note**: we use two headless services for routing HTTPS requests to pods to help with down scaling the bots. + +#### ClusterIP Service + +One ClusterIP Service is deploy for each pod deployed using `.Values.scale.maxReplicaCount`. Each service points to a specific pod in the StatefulSet. This service is responsible for routing TCP traffic to the pod. + +Each service exposes and maps `.Values.internal.media` to the pod in the StatefulSet. + +#### ConfigMap + +A ConfigMap is used to bind the external TCP port exposed by the external load balancer to a specific pod in the StatefulSet. To do this, when deploying Ingress-Nginx it is important to pass an argument to the container telling the bot where to find the ConfigMap. If you're deploying Ingress-Nginx using Helm, you can do this with `--set controller.extraArgs."tcp-services-configmap"=teams-recording-bot/tcp-services`. + +When deploying, Helm iterates `.Values.scale.maxReplicaCount` number of times, and creates an entry for `.Values.public.media` + current iteration, mapping it to a specific ClusterIP Service and `.Values.internal.media`. + +The end result is something like this: + +```yaml +28550: teams-recording-bot/teams-recording-bot-0:8445 +``` + +#### StatefulSet + +The bot deploys using a StatefulSet. The reason for this is predictable pod names as pods in a StatefulSet are ordinal indexed and StatefulSets make it easy to do things like rolling updates. + +### How scaling works + +This section describes both scaling up and scaling down. + +#### Scaling up + +Scaling up is pretty easy. When scaling up all you need to do is increase `.scale.replicaCount` and let Helm take care of the rest. + +The main thing that happens when increasing `.scale.replicaCount` is: + +* The StatefulSet is updated and new pods begin to spin up. + +If you want to deploy more bots than what `scale.maxReplicaCount` allows, increasing `scale.maxReplicaCount` will do the following: + +* New ports are opened up on the external load balancer. +* Additional ClusterIP services are added for each additional pods. +* The ConfigMap `tcp-services` is updated with mapping between the newly added ports and ClusterIP services. + +The reason for separating scaling pods from scaling the services which support routing media is so that we can ensure the services will remain active while scaling down. This also helps if you want to implement auto scaling through something like [Horizontal Pod Autoscaler](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/). + +#### Scaling down + +Scaling down is a bit tricky. There's a couple issues we face when scaling down. + +* If a pod is in a meeting, we need to make sure the pod is not forcefully terminated before the meeting finishes on its own. +* If a pod is terminating, we don't want new calls being assigned to that pod. +* When a pod is marked for termination, that pod's endpoints are removed from all services. This means even if we are able to keep the pod alive, we would not be able to route update notifications to the pod using the headless service and NGINX server-snippet. + +To get around pods terminating before the call has ended is through a combination of `terminationGracePeriodSeconds` and `preStop` hooks. When used together, we can keep the pods alive for as long as we want without risk of encountering a `SIGKILL` signal. + +In our `preStop` hook, we run a little script called [halt_termination.ps1](../../scripts/halt_termination.ps1). This script calls `http://localhost:9442/calls` (an API endpoint built into the bot), and if the response is empty, there are no ongoing calls and the script exits allowing the pod to finish terminating. However, if the response is not empty, that indicates the pod is currently in an ongoing meeting so the script then sleeps for 60 seconds before trying again. + +**Note**: if you set `terminationGracePeriodSeconds` too low you risk running out of time which results in the pod being forcefully removed. + +So that gets around the pod forcefully terminating but that doesn't fix the issue of the pod's endpoints getting removed from all services. For that we set `publishNotReadyAddresses` to `true` in one of our headless services. This prevents the terminating pod's endpoints from getting removed from that headless service. This in turn allows the NGINX server-snippet to continue routing update notification HTTPS traffic to the pod even while it is terminating! + +This is also why we have two headless services to route HTTPS traffic to the pods. One headless service is strictly to round-robin new requests for a bot to join a meeting while the other is used to route update notifications directly to the desired pod. When the pod is marked from termination, that pod's endpoint is removed from the headless service that round-robins requests to an available pod preventing new calls going to that terminating pod, while leaving that pod's endpoints reachable by the headless service with `publishNotReadyAddresses` set to `true`. + +To scale down, decrease `scale.replicaCount` while leaving `scale.maxReplicaCount` as is. If you want to decrease `scale.maxReplicaCount`, make sure you do so after decreasing `scale.replicaCount` and waiting for there to be no pods in a `terminating` state. + +## Validate deployment + +You can validate that the `cert-manager` and `ingress-nginx` pods are running properly by checking the pods' statuses: + +```powershell +kubectl get pods -n cert-manager +kubectl get pods -n ingress-nginx +``` + +which should show the pods running: + +``` +NAME READY STATUS RESTARTS AGE +cert-manager-abcdefg 1/1 Running 0 10m +cert-manager-cainjector-tuvwxyz 1/1 Running 0 10m +cert-manager-webhook-1234567 1/1 Running 0 10m +``` + +``` +NAME READY STATUS RESTARTS AGE +nginx-ingress-ingress-nginx-controller-abcdefg 1/1 Running 0 10m +``` + +Also validate the ingress controller service: + +```powershell +kubectl get svc -n ingress-nginx +``` + +which should show the configured static IP and open ports: + +``` +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +nginx-ingress-ingress-nginx-controller LoadBalancer 10.0.101.71 12.123.12.123 80:31871/TCP,443:31066/TCP,28550:30992/TCP,28551:32763/TCP,28552:31163/TCP 10m +``` + +Next, validate that the certificate was properly issued: + +```powershell +kubectl get cert -n teams-recording-bot +``` + +which should show the issued certificate as "ready": + +``` +NAME READY SECRET AGE +ingress-tls True ingress-tls 10m +``` + +Finally, you can check the `teams-recording-bot` pods: + +```powershell +kubectl get pods -n teams-recording-bot +``` + +which should show the 3 bot pods as "running": + +``` +NAME READY STATUS RESTARTS AGE +teams-recording-bot-0 1/1 Running 1 10m +teams-recording-bot-1 1/1 Running 1 10m +teams-recording-bot-2 1/1 Running 1 10m +``` + +**Note**: There may be a different number of pods if you changed the `scale.replicaCount` in `values.yaml`. + +## Testing + +After all kubernetes services and pods are up and running, it is a good idea to test that your bot can actually join the Microsoft Teams meeting and properly record audio streams. +To do that, you'd need to follow these steps: + +1. Create a Teams Meeting Recording Policy for your Bot and assign it to the test user as instructed in [Policy.md](../setup/policy.md). + +2. Join the meeting with a test user (that has the Teams Meeting Recording Policy assigned to) and verify that the recording has automaticaly started (you should see the user consent disclaimer that the recording has started). + +3. Either install the [Kubernetes Tools](https://marketplace.visualstudio.com/items?itemName=ms-kubernetes-tools.vscode-kubernetes-tools) extension for your Visual Studio Code or use the command line utility `kubect` to find which of your pods is currently serving the Recording bot. If you use the VSC extension, simply open the Kubernetes tab, then find the pods under `teams-recording-bot` namespace, right click on each of them (if you have more than 1 replica) and select `Show Logs`. +And if you use `kubect` command line utility, execute the following commands: + +1.Get Pods + +```powershell +kubectl get pods -n teams-recording-bot +``` + +You'd see something like this output: + +```cmd +NAME READY STATUS RESTARTS AGE +teams-recording-bot-0 1/1 Running 0 13h +teams-recording-bot-1 1/1 Running 0 13h +teams-recording-bot-2 1/1 Running 0 13h +``` + +2.Obtain logs for each pod + +```powershell +kubectl logs -n teams-recording-bot teams-recording-bot-{X} +``` + +>where {X} is the number of your pods. + +For example: `teams-recording-bot-0` + +Eventually, you will find that one of your pods have the logs describing that your bot has successfully joined (or failed to join) the meeting. In the former case, you'd see something like this: + +```cmd +Setup: Starting VC_redist +Setup: Converting certificate +Setup: Installing certificate +Certificate "avbotaks.msftdevtest.com" added to store. + +CertUtil: -importPFX command completed successfully. +Setup: Deleting bindings +Setup: Adding bindings +Setup: Done +--------------------- +RecordingBot: booting +RecordingBot: running +Skipped publishing IncomingHTTPPOST events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing NotificationHTTPPOST events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing CallEstablishing events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing NotificationHTTPPOST events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing CallEstablishing events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing NotificationHTTPPOST events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing CallEstablished events to Event Grid topic recordingbotevents - No topic key specified +Skipped publishing CallRecordingFlip events to Event Grid topic recordingbotevents - No topic key specified +``` + +3.Obtain the recorded audio files +After finishing the test MS Team meeting, you'd need to obtain the location and the file name of the zip file containing the audio stream produced by the Recording Bot on one of your AKS pods. To do that, you'd need to connect and create a PowerShell session on that pod. For simplicity, let's assume that your Bot is currently serving your Teams meeting on the Pod #1. + +```powershell +kubectl exec -it teams-recording-bot-1 -n teams-recording-bot -- powershell.exe +``` + +When connected, you should see the PowerShell command line inside of your pod: + +```cmd +Windows PowerShell +Copyright (C) Microsoft Corporation. All rights reserved. + +PS C:\bot> +``` + +From there, navigate to the `C:\Users\ContainerAdministrator\AppData\Local\Temp\teams-recording-bot` directory should be existed on your pod and check what sub-folders exist underneath. You should be able to find the sub-folder with the name `archive`, and then, after inspecting it, you should find the folder containing the archived, zip file you need to take a full path of. + +To get the recorded audio files copied from your AKS pod into your local machine, you can use the `kubectl` `cp` command: + +```powershell +kubectl cp -n teams-recording-bot teams-recording-bot-{pod-#}:{path-to-zip} ./{local-zip} +``` + +where: + +>- {pod-#}: Your pod number containing the audio files. +>- {path-to-zip}: The path to the source zip file on your Pod excluding the drive letter sign +>- {local-zip}: The local file name to the destination zip file. + +For example, your command may look something like this: + +```powershell +kubectl cp -n teams-recording-bot teams-recording-bot-1:/Users/ContainerAdministrator/AppData/Local/Temp/teams-recording-bot/archive/3c608c13-e57d-4aa1-bb6f-f153a12e0a05/c133ccdf-0abb-41ca-9134-0203f5c6c797.zip ./audio.zip +``` + +Now you can inspect the archived zip file on your local drive and check that it contains the audio stream was recorded during your test Teams Meeting. + +## Upgrading + +Upgrades are handled through helm. To upgrade the bot in your cluster, pull the latest changes and run the following command from the root of this repository: + +```powershell +helm upgrade teams-recording-bot ./deploy/teams-recording-bot ` + --namespace teams-recording-bot ` + --set host="HOST" ` + --set public.ip=STATIC_IP_ADDRESS ` + --set image.domain=YOUR_ACR_DOMAIN +``` + +## Scaling + +You can manually scale the bot by increasing the `scale.replicaCount`. Run the following command from the root of this repository: + +```powershell +helm upgrade teams-recording-bot ./deploy/teams-recording-bot ` + --namespace teams-recording-bot ` + --set host="HOST" ` + --set public.ip=STATIC_IP_ADDRESS ` + --set scale.replicaCount=2 +``` + +**Note**: `scale.replicaCount` cannot be greater than `scale.maxReplicaCount`. If you need to deploy more bots then be sure to also use `--set scale.maxReplicaCount=SOME_GREATER_NUMBER`. + +## Updating Docker Image + +After making new changes to the application code, you need to follow these steps to also update and release your new code into Azure Kubernetes Service: + +1. Build the container, by opening a new powershell terminal and make sure you've changed directories to the root of this repository. If you are, run the following command: + + ```powershell + docker build ` + --build-arg CallSignalingPort= ` + --build-arg CallSignalingPort2= ` + --build-arg InstanceInternalPort= ` + -f ./build/Dockerfile . ` + -t [TAG] + ``` + + >Note: the `[TAG]` should correspond to the tag was given to the Docker image deployed to your Azure Container Registry: `youracrname.azurecr.io/teams-recording-bot:[new-version-number]` + +2. Push the new image into Azure Container Registry: + +```powershell +az acr login --name youracrname +``` + +and then pushing the image: + +```powershell +docker push youracrname.azurecr.io/teams-recording-bot:[new-version-number] +``` + +3. When updating the docker image, you must also update the application version specified in the [Chart.yaml](../deploy/teams-recording-bot/Chart.yaml). This version number should be incremented each time you make changes to the application. +Then, you simply re-run the Helm Chart upgrade command in order to update your AKS cluster pods: + +```powershell +helm upgrade teams-recording-bot ./deploy/teams-recording-bot ` + --namespace teams-recording-bot ` + --set host="HOST" ` + --set public.ip=STATIC_IP_ADDRESS ` + --set image.domain=YOUR_ACR_DOMAIN +``` + +>where: +- YOUR_ACR_DOMAIN is your full path to your ACR registry (e.g. `youracrname.azurecr.io`) +- STATIC_IP_ADDRESS is your public static IP address attached to the AKS Load Balancer + +## Uninstalling + +```powershell +helm uninstall teams-recording-bot --namespace teams-recording-bot + +kubectl delete ns cert-manager + +kubectl delete ns ingress-nginx +``` diff --git a/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-overview.md b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-overview.md new file mode 100644 index 000000000..1bffa6729 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-overview.md @@ -0,0 +1,72 @@ +# High Level Overview + +![Image 1](../images/Overview.svg) + +As seen in the image, Microsoft Teams clients communicate with the Micorsoft Teams platform, which +communicates with the bot recording application via different channels. The Microsoft Teams platform +gets a URL pointing to the notifications endpoint of the bot recording application from a bot service. + +The following list represents the default flow of a one to one call or meeting, initiated by a +Microsoft Teams user. + +1. The users Microsoft Teams client connects to the Microsoft Teams platform. +2. The Microsoft Teams platform evaluates the compliance recording policies. + + - If the user is under a policy: + +3. The Microsoft Teams platform loads the HTTPS notification URL from the corresponding bot service. +4. The Microsoft Teams platform creates a call (an entity) on the Graph Communications API for the + bot recording application, with destination to the users call and metadata of the users call. +5. The call is sent to the notification URL of the bot recording application. + +> [!IMPORTANT] +> The notification URL must be HTTPS and the certificate used must be signed by a valid authority. + +When the bot recording application receives the notification with the new call from the Microsoft +Teams platform, the application should answer the call via the Graph Communications API. The API +request to answer must contain some configuration e.g. the TCP endpoint of the media processor of +the bot or a new notification URL for further notifications regarding the call. The recording bot +application receives notifications via the aforementioned URL for users joining, leaving, muting, +starting screen shares and more. + +After the bot recording application answered and accepted a call, the Microsoft Teams platform +opens a connection on the provided TCP endpoint. After an initial handshake for authorization +and encryption, metadata and media events are transferred via the TCP connection. + +> [!IMPORTANT] +> The TCP endpoint also requires a certificate signed by a valid authority. The Certificate for the +> HTTPS endpoint and the TCP endpoint **can** be the same. + +## Graph Communications SDK + +The overhead of the TCP endpoints is completly managed by the Graph Communications SDK, but the +HTTPS endpoints for notifications must be custom implemented by the bot recording application. +ASP.NET Core can be used. After a HTTP request is authorized and notifications are parsed from the +request, the notifications should be forwarded to the SDK. The SDK will process notifications and +fire events based on these notifications. The event handlers can implement business logic. An +event handler that, for example, triggers when a new call notification was received, should be used +for answering calls. Before answering, business logic can decide whether the call should be +accepted. Such an event handler should use the `answer` method of the SDK with the desired +configuration to accept a call. The configuration specifies how many video sockets should be +created, if the notification url changes and more. In the case that a call should not be accepted, +a `reject` method of the SDK can be used. + +As the Microsoft Graph Communications SDK takes care of a lot of overhead and endpoint handling, +the SDK also needs to be configured correctly with: + +- a valid certificate as part of the TCP endpoint configuration +- DNS name +- port(s) +- Notification Endpoint configuration +- Graph Communications API connection configuration + +The SDK also needs an implementation of an authorization handler for: + +- API calls to the Graph Communications API +- validating incoming requests (within the initial TCP handshake of the SDK) +- (optionally) validating HTTP requests on the custom implemented notification endpoint + +> [!NOTE] +> The application requires some application permissions on an app registration that is bound to a +> bot service to be able to answer calls and access media from calls, see the +> [Application Permissions Page](./recording-bot-permission.md) for reference. diff --git a/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-permission.md b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-permission.md new file mode 100644 index 000000000..1109d534d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-permission.md @@ -0,0 +1,39 @@ +# Bot Service - Microsoft Entra Id and Micosoft Graph API Calling Permission + +An Azure bot service can be created with an existing app registration in the Microsoft Entra Id or +can create a new app registration in the Microsoft Entra Id on creation. The app registration can +be a single tenant app registration or a multi tenant app registration, also see +["Tenancy in Microsoft Entra ID" documentaion](https://learn.microsoft.com/entra/identity-platform/single-and-multi-tenant-apps). Either way it is required that the Microsoft Entra + Id app registration and the bot service are linked. + +Also the app registration must have some application permissions exposed by the Microsoft Graph API +that allows the recording application to join calls and access the media streams. The following +Microsoft Graph API application permissions are relevant for the recording bot: + +| permission | description | +| ------------ | ------------- | +| [Calls.Initiate.All](https://learn.microsoft.com/graph/permissions-reference#callsinitiateall) | Allows the app to place outbound calls to a single user and transfer calls to users in your organization's directory, without a signed-in user. | +| [Calls.InitiateGroupCall.All](https://learn.microsoft.com/graph/permissions-reference#callsinitiategroupcallall) | Allows the app to place outbound calls to multiple users and add participants to meetings in your organization, without a signed-in user. | +| [Calls.JoinGroupCall.All](https://learn.microsoft.com/graph/permissions-reference#callsjoingroupcallall) | Allows the app to join group calls and scheduled meetings in your organization, without a signed-in user. The app will be joined with the privileges of a directory user to meetings in your tenant. | +| [Calls.JoinGroupCallasGuest.All](https://learn.microsoft.com/graph/permissions-reference#callsjoingroupcallasguestall) | Allows the app to anonymously join group calls and scheduled meetings in your organization, without a signed-in user. The app will be joined as a guest to meetings in your tenant. | +| [Calls.AccessMedia.All](https://learn.microsoft.com/graph/permissions-reference#callsaccessmediaall) | Allows the app to get direct access to participant media streams in a call, without a signed-in user. | + +Not all of them are necessary for a compliance recording bots. A compliance recording only requires: + +- Calls.AccessMedia.All +- Calls.JoinGroupCall.All +- Calls.JoinGroupCallAsGuest.All + +> [!IMPORTANT] +> After configuring the application permissions it is required that a Microsoft Entra Id +> administrator grants the permissions. This also applies for any time the application permissions +> are changed. Changes made to the application permissions of an app registration will not take +> effect until a Microsoft Entra Id administarator has consented to them. + +It is possible for administrators to grant the application permissions in the [Azure portal](https://portal.azure.com), +but often a better option is to provide a sign-up experience for administrators by using the +Microsoft Entra Id `/adminconsent` endpoint, see [instructions on constructing an Admin Consent URL](https://learn.microsoft.com/entra/identity-platform/v2-admin-consent). + +> [!Note] +> Application permissions of a multi tenant app registration must be granted by an administrator of +> each targeted Microsoft Entra Id tenant. diff --git a/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-policy.md b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-policy.md new file mode 100644 index 000000000..e4a9efa85 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/explanations/recording-bot-policy.md @@ -0,0 +1,55 @@ +# Compliance Recording Policies + +> [!TIP] +> Also read the [How To Recording Policies Guide](../guides/policy.md) + +Compliance recording policies are the connection between Microsoft Teams users doing calls and bot +recording application ready to record. If a Microsoft Teams user tries to do a call, the Microsoft +Teams platform evaluates the compliance recording policies of the user. When policies are assigned +to a user or to a group of the user the policy is further evaluated. Policies always have to be set +up for each Microsoft Entra Id tenant induvidualy. + +Policies itself are complex to setup and allow for a lot of options. It is possible to configure +the policy to invite multiple recording bots for resilience, or that it is allowed for users to do +calls without a recording bot present. + +Recording policies are made of 3 parts: + +- [Recording Policy](#recording-policies) +- [Recording Application](#recording-applications) +- [Application Instance](#application-instances) + +When the Microsoft Teams platform finds a recording policy assigned to an user, it further +evaluates the recording application and loads the app registration from the corresponding +application instance. The Microsoft Teams platform then gets the notification URL from the bot +service that is linked to the app registration and sends the notification to the recording bot via +the notification URL in the bot service. + +## Recording Policies + +Recording policies are managable instances that can be assigend to users, groups of users or to a +whole tenant (all users of a tenant). Policies contain a recording application. + +## Recording Applications + +Recording applications define how the Microsoft Teams platform should behave with the policy. A +recording application can define, that the recording bot has to be present before the user is +allowed to establish a connection to the call, that users disconnect if the bot leaves or rejects a +call, or that an audio message should be played if the recording bot sets the recording status to +recording. A recording application can also define a paired recording application that is also +invited. This can, for example, be used as backup to raise resilience. + +## Application Instances + +An application instance is the connection between the recording application for the policy and the +app registration of the bot implementation. The application instance creates a Microsoft Entra Id +resource that is linked to the app registration, also in Entra Id. The app registration and the +application instance do not have to be in the same Entra Id tenant. This is especially interesting +for multi tenant recording bot implementations as it allows the hosting tenant to configure things +like permissions and the notification URL and the consumer tenants to define the behaviour of the +recording policy. + +An application instance is a Microsoft Entra Id resource similar to a user. Application instances +also require user principal names to be set. When the Microsoft Teams platform evaluates the +recording policies, an application instance delivers the app registration that is linked to a bot +service that contains the notification url. diff --git a/Samples/PublicSamples/RecordingBot/docs/guides/policy.md b/Samples/PublicSamples/RecordingBot/docs/guides/policy.md new file mode 100644 index 000000000..df8ea388f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/guides/policy.md @@ -0,0 +1,186 @@ +# Compliance Recording Policy + +The following steps should be done by an Microsoft Entra Id administrator. + +The Microsoft Entra Id tenant will require at least one user allocated for the bot to be used. +This can be an existing user or a new one can be created e.g. for testing. + +## Prerequisites + +- PowerShell (5.1, comes with Windows) as Administrator +- PowerShell execution policy of at least `RemoteSigned` +- PowerShell Module + - [SkypeForBusiness](https://learn.microsoft.com/powershell/module/skype/) + - or [MicrosoftTeams](https://learn.microsoft.com/powershell/module/teams/) +- A Microsoft Entra Id administrator + +Throughout this Documentation we will use the `MicrosoftTeams` PowerShell Module. +You can also use the SkypeForBusiness Module, either one works. +The relevant Commands are available in both Modules. + +You will use the Microsoft Entra Id administrator for all commands. + +### Run PowerShell (as admin) + +Just hit the Widnows Key on your Keyboard or click the Start menu button. +Now start typeing `PowerShell`. +Open the Context Menu (right click) of the `Windows PowerShell` entry and select `Run as Administrator`. + +### Enable PowerShell Script execution + +In an evelated (Run as Admin) PowerShell Terminal execute the following command +`Set-PsExecutionPolicy RemoteSigned` +For more information look [here](https://learn.microsoft.com/powershell/module/microsoft.powershell.security/set-executionpolicy) + +### Install the Module + +In an evelated (Run as Admin) PowerShell Terminal execute the following command +`Install-Module MicrosoftTeams` +Or if it is already installed, update the module +`Update-Module MicrosoftTeams` + +### Activate the Module + +In an evelated (Run as Admin) PowerShell Terminal execute the following command +`Import-Module MicrosoftTeams` +and then +`Connect-MicrosoftTeams` +You will need to sign in with your Azure Credentials (Microsoft Entra Id administrator) here + +For further Information check [Install the Microsoft Teams PowerShell Module](https://learn.microsoft.com/microsoftteams/teams-powershell-install#installing-using-the-powershellgallery) and [sign in with your Azure Credentials](https://learn.microsoft.com/microsoftteams/teams-powershell-install#sign-in) + +## Setup a Compliance Policy for Teams + +>Note: All of the following commands are executed in the PowerShell Terminal that you have used to Activate the module. + +To create a policy in Teams, we need 3 objects. + +- An [Application Instance](../explanations/recording-bot-policy.md#application-instances) (Microsoft Entra ID Resource) +- A [Recording Policy](../explanations/recording-bot-policy.md#recording-policies) (the actual compliance policy) +- A [Recording Application](../explanations/recording-bot-policy.md#recording-applications) (A link between the policy and the application) + +### Create the Application Instance + +[New-CsOnlineApplicationInstance](https://learn.microsoft.com/powershell/module/skype/new-csonlineapplicationinstance) + +```powershell +New-CsOnlineApplicationInstance + -UserPrincipalName ` + -DisplayName ` + -ApplicationId +``` + +The Application Id might not be from your own Microsoft Entra ID, +but from the Microsoft Entra ID that hosts this Bot. +So basically from the Service Provider. + +You will now have to Synchronize this Application Instance into the Agent Provisioning Service + +```powershell +Sync-CsOnlineApplicationInstance + -ObjectId + -ApplicationId +``` + +You can get the Object Id by executing the `Get-CsOnlineApplicationInstance -Displayname ` and checking the output. +The Object Id will also be displayed after the `New-CsOnlineApplicationInstance` command. +The Application Id is the same Application Id you used to create the Application Instance. + +### Create the policy + +[New-CsTeamsComplianceRecordingPolicy](https://learn.microsoft.com/powershell/module/skype/new-csteamscompliancerecordingpolicy) + +```powershell +New-CsTeamsComplianceRecordingPolicy + -Identity + -Enabled $true +``` + +With this, you will just have a policy. +You can already assign this policy to users, but it will not do anything, +because it does not have any Recording Applications assigned to it yet. + +### Create the Recording Application + +[New-CsTeamsComplianceRecordingApplication](https://learn.microsoft.com/powershell/module/skype/new-csteamscompliancerecordingapplication) + +```powershell +New-CsTeamsComplianceRecordingApplication + -Parent + -Id +``` + +The Parent is the Parameter `Identity` from the `New-CsTeamsComlianceRecordingPolicy`. +So the Parent of a Recording Application is a Recording policy. +The Id is the Object ID of the Object created with `New-CsOnlineApplicationInstance`. +So basically you now have + +- assigned an Application in your own Entra (Application Instance) + - wich points to an application on an external Entra (Application Id that was assigned to Application Instance) +- to a Teams Compliance Policy (by using its name) + +## Use the policy + +To be able to use the Policy, you will need to assign the policy to Users or Groups. + +> [!NOTE] +> It may take a few minutes and logged in users need a new access token (logout and login again) before the recording policy takes effect. + +### Assign the Policy to a tenant + +[Grant-CsTeamsComplianceRecordingPolicy](https://learn.microsoft.com/powershell/module/teams/grant-csteamscompliancerecordingpolicy) + +``` powershell +Grant-CsTeamsComplianceRecordingPolicy + -Global + -PolicyName +``` + +This assigns the policy to all users of your tenant. + +### Assign the Policy to a user + +[Grant-CsTeamsComplianceRecordingPolicy](https://learn.microsoft.com/powershell/module/teams/grant-csteamscompliancerecordingpolicy) + +``` powershell +Grant-CsTeamsComplianceRecordingPolicy + -Identity + -PolicyName +``` + +This assigns the policy to the user specified by its user principal name(UPN). +The UPN is often also the email address of the user, but it does not have to be. +The upn of a user can be found in the user overview of the [Microsoft Entra Admin Center](https://entra.microsoft.com). + +To verify if the policy was successfully assigned, you can run: + +``` powershell +Get-CsOnlineUser | ft sipaddress, tenantid, TeamsComplianceRecordingPolicy +``` + +If the policy has been assigned successfully the output should look similar to + +``` text +SipAddress TenantId TeamsComplianceRecordingPolicy +---------- -------- ------------------------------ +sip: 00000000- +``` + +### Assign the Policy to a group + +[Grant-CsTeamsComplianceRecordingPolicy](https://learn.microsoft.com/powershell/module/teams/grant-csteamscompliancerecordingpolicy) + +``` powershell +Grant-CsTeamsComplianceRecordingPolicy + -Group + -PolicyName +``` + +This assigns the policy to all users of the group specified by the object id of the group. +Groups can be security groups and Microsoft 365 groups, +the object id of a group can be found in the group overview of the [Microsft Entra Admin Center](https://entra.microsoft.com). + +## Remove Recording Policy Assignment + +Removing a recording policy Assignment is very similar to assigning a recording policy. +Passing `$null` as the `PolicyName` parameter will remove a recording policy. diff --git a/Samples/PublicSamples/RecordingBot/docs/images/Overview.svg b/Samples/PublicSamples/RecordingBot/docs/images/Overview.svg new file mode 100644 index 000000000..78ca536ab --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/images/Overview.svg @@ -0,0 +1,4 @@ + + + +
Teams Web or GUI
User Space
Azure Cloud
Graph Communications API
Teams Platform
Teams Server
Bot Service
Bot Host Environment 
(Could be Azure)
Bot Recording Application
Web Hook Config/
Notification Url
Media and Metadata
HTTPS Signaling
TCP Media
Notification Url
HTTP API
\ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/docs/images/k8s.png b/Samples/PublicSamples/RecordingBot/docs/images/k8s.png new file mode 100644 index 000000000..0aa799f5c Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/k8s.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/policy-result.png b/Samples/PublicSamples/RecordingBot/docs/images/policy-result.png new file mode 100644 index 000000000..c103bb303 Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/policy-result.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/policy_output.png b/Samples/PublicSamples/RecordingBot/docs/images/policy_output.png new file mode 100644 index 000000000..9c4cd478a Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/policy_output.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/screenshot-login.png b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-login.png new file mode 100644 index 000000000..c3bd1c28d Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-login.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/screenshot-no-calls-web-page.png b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-no-calls-web-page.png new file mode 100644 index 000000000..0e543d58a Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-no-calls-web-page.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/screenshot-recording-banner.png b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-recording-banner.png new file mode 100644 index 000000000..b8ef5dc78 Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/screenshot-recording-banner.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/images/screenshot1.png b/Samples/PublicSamples/RecordingBot/docs/images/screenshot1.png new file mode 100644 index 000000000..bf49e9d01 Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/docs/images/screenshot1.png differ diff --git a/Samples/PublicSamples/RecordingBot/docs/setup/bot.md b/Samples/PublicSamples/RecordingBot/docs/setup/bot.md new file mode 100644 index 000000000..347269fe6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/setup/bot.md @@ -0,0 +1,27 @@ +# Bot Registration + +1. Follow the steps [Register your bot in the Azure Bot Service](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/articles/calls/register-calling-bot.html#register-your-bot-in-the-azure-bot-service). Save the bot name, bot app id and bot secret for configuration. We'll refer to the bot name as `BOT_NAME`, app ID as `BOT_ID` and secret as `BOT_SECRET`. + + * For the calling webhook, by default the notification will go to https://{RESERVED_DOMAIN}/api/calling. This URL prefix is configured with the `CallSignalingRoutePrefix` in [HttpRouteConstants.cs](../../src/RecordingBot.Model/Constants/HttpRouteConstants.cs#L21). + + * Ignore the guidance to [Register bot in Microsoft Teams](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/articles/calls/register-calling-bot.html#register-bot-in-microsoft-teams). The recording bot won't be called directly. These bots are related to the policies discussed below, and are "attached" to users, and will be automatically invited to the call. + +2. Add the [Add Microsoft Graph permissions for calling to your bot](https://microsoftgraph.github.io/microsoft-graph-comms-samples/docs/articles/calls/register-calling-bot.html#add-microsoft-graph-permissions-for-calling-to-your-bot) - not all these permissions _may_ be required, but this is the exact set that we have tested with the bot with so far: + + * Calls.AccessMedia.All + * Calls.JoinGroupCall.All + * Calls.JoinGroupCallAsGuest.All + +3. Set Redirect URI (It can be any website. Take a note of the URI input) + * In Azure portal: Your bot (Bot Channels Registration) > Settings > Manage > Authentication > Add a platform > Web: Configure your redirect URI of the application + > **ToDo**: Add a sample URI redirect! + + > **ToDo**: Add the instructions for either the implicit grant flow must be enabled or left untouched. + +4. The Office 365 Tenant Administrator needs give consent (permission) so the bot can join Teams and access media from that meeting + * Ensure that you have the `TENANT_ID` for the Office 365 (not Azure AD) tenant that the bot will be using to join calls. + * Go to `https://login.microsoftonline.com/{TENANT_ID}/adminconsent?client_id={BOT_ID}&state=&redirect_uri=` using tenant admin to sign-in, then consent for the whole tenant. After hitting Accept, you should be redirected to your redirect URI. + + ```text + https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/adminconsent?client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&state=0&redirect_uri=https://mybot.requestcatcher.com/ + ``` diff --git a/Samples/PublicSamples/RecordingBot/docs/setup/certificate.md b/Samples/PublicSamples/RecordingBot/docs/setup/certificate.md new file mode 100644 index 000000000..f48defd66 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/setup/certificate.md @@ -0,0 +1,62 @@ +# Setting up SSL Certificate + +## Generate SSL Certificate + +### Requirements + +- Follow [Ngrok installation and configuration guide](./ngrok.md) +- Follow [Certbot installation guide for Windows](https://certbot.eff.org/lets-encrypt/windows-other.html) or [Certbot installation guide for Ubuntu](https://certbot.eff.org/lets-encrypt/ubuntufocal-other) +- Follow [OpenSSL installation guide](./openssl.md) + +### Instructions + +For this example, I'm using my Ngrok reserved domain of jodogrok. Go get your own! + +This will connect Ngrok, set up SSL and save it to `/letsencrypt` or the `etc` folder if you run it on Windows. + +Please note, there is a limit on how many times you can do LetsEncrypt (like maybe 5 a week!) so save your `letsencrypt` folder. + +If the `letsencrypt` folder exists, it will use these certs instead (will copy them to the right place in the container). If you change your ngrok domain name, you will have to delete this folder first as the certs will not work. + +- Go to [Ngrok](https://dashboard.ngrok.com/get-started) and login. You will need a pro plan for this +- Reserve your name (I did jordogrok) +- Edit `config.ini` and replace with your email and your domain name (`jordogrok.ngrok.io` was mine. Note, the example on Ngrok site has "au" in it - leave this out) +- Edit `config.sh` and replace + - `SUBDOMAIN=jodogrok` + - `AUTHTOKEN=get from Ngrok dash under (3) Connect your account` + - `CERTIFICATEPASSWORD=password used when saving certificate.pfx` +- Edit `ngrok.yaml` and replace `SUBDOMAIN` with your subdomain. + +Open a Windows Terminal, run `./host.sh` and you're off to the races! Access your domain to see the site that you're redirecting to. + +Make sure your browser tells you the cert is working. + +You may need to change the host networking type in `.devcontainer/docker-compose.yaml` if you are not seeing results of the forwarding. + +## Installing URL ACL and Certificate Bindings + +Once you have finished [Setting up Ngrok](https://github.com/microsoft/netcoreteamsrecordingbot/blob/master/docs/setup/ngrok.md) , lets generate our own signed SSL certificates using our newly reserved domains. + +1. Follow the instructions in [Generate SSL Certificate](##generate-ssl-certificate) on this page to configure and run [`host.sh`](../../scripts/config.sh) script. This will produce a SSL certificate we can then use for this project. Make sure when you configure the project to use the `RESERVE_DOMAIN` you created earlier. +2. To install your newly created certificate, hit `WIN+R` on your keyboard and type `mmc`. +3. `File -> Add/Remove Snap In...` +4. Add `Certificates`. You'll see a popup. Make sure you select `Computer account` and `Local computer` is selected before clicking `Finish`. +5. Next, expand `Certificates (Local Computer)` -> `Personal` and click on `Certificates`. +6. You should see a bunch of certificates. Right click -> `All Tasks` -> `Import...` +7. Browse for your `certificate.pfx`. Make sure you change the file extension to `Personal Information Exchange...`. Click next, enter your certificate's password, and click through until the certificate is loaded. +8. Now you should see your certificate. Double click on it -> click on `Details` -> scroll down to the bottom and you'll see `Thumbprint`. Copy and paste it somewhere save. We'll refer to this as `THUMBPRINT`. + +Once you've got your thumbprint... + +1. Create a new file in `build/` called `certs.bat`. +2. Copy the contents of [certs.bat-template](../../scripts/certs.bat-template) to `certs.bat`. +3. Replace `YOUR_CERT_THUMBPRINT` in [certs.bat](../../scripts/certs.bat-template#L20-#L21) with `THUMBPRINT`. +4. Run the bat file in a new command prompt with administrator privileges. + +**NOTE:** if your certificate expires, you'll need to regenerate it and repeat all the steps again, including running `certs.bat` with the new `THUMBPRINT`. You'll also need to update `AzureSettings__CertificateThumbprint` in your `.env` file. + +### Troubleshooting + +#### Issues related to bash commands + +Make sure line endings are in unix format. Use `dos2unix` if Windows `git` checked out files in with incompatible line endings. diff --git a/Samples/PublicSamples/RecordingBot/docs/setup/ngrok.md b/Samples/PublicSamples/RecordingBot/docs/setup/ngrok.md new file mode 100644 index 000000000..862dc19bb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/setup/ngrok.md @@ -0,0 +1,79 @@ +# Ngrok + +From the Ngrok documentation – "`ngrok allows you to expose a web server running on your local machine to the internet.`" + +## Local debugging + +A benefit of using Ngrok is the ability to [debug your channel locally](https://blog.botframework.com/2017/10/19/debug-channel-locally-using-ngrok/). +Specifically Ngrok can be used to forward messages from external channels on the web directly to our local machine to allow debugging, as opposed to the standard messaging endpoint configured in the Bot Framework portal. + +Messaging bots use HTTP, but calls and online meeting bots use the lower-level TCP. Ngrok supports TCP tunnels in addition to HTTP tunnels. Since Ngroks public TCP endpoints have fixed URLs you should have a DNS `CNAME` entry for your service that points to these URLs. +This enables the Microsoft media service to connect with the local bot. + + +More information can be found here: +- [How to develop calling and online meeting bots on your local PC](https://docs.microsoft.com/en-us/microsoftteams/platform/bots/calls-and-meetings/debugging-local-testing-calling-meeting-bots). +- [Tips on debugging your local development environment](..\debug.md) + +Notes: +Please note that while local bot debugging can be performed, it is not a method Microsoft officially supports. +Since Ngrok free accounts don't provide end-to-end encryption you will need to consider a paid Ngrok account for which the installation instructions are as follows: + +## Ngrok Installation + +### Installing Ngrok on Windows + +#### Use the Chocolatey Package Manager + +If you use the Chocolatey package manager (highly recommended), installation simply requires the following command from an elevated command prompt: + +```powershell +choco install ngrok.portable +``` + +This will install Ngrok in your PATH so you can run it from any directory. + +#### Install Manually + +Installing Ngrok manually involves a few more steps: + +1. Download the Ngrok ZIP file from this site: https://ngrok.com/download +2. Unzip the `ngrok.exe` file +3. Place the `ngrok.exe` in a folder of your choosing +4. Make sure the folder is in your PATH environment variable + +#### Test Your Installation + +To test that Ngrok is installed properly, open a new command window (command prompt or PowerShell) and run the following: + +```powershell +ngrok version +``` + +It should print a string like "ngrok version 2.x.x". If you get something like "'ngrok' is not recognized" it probably means you don't have the folder containing ngrok.exe in your PATH environment variable. You may also need to open a new command window. + +### Installing Ngrok on Linux (Ubuntu 18/20) or WSL + +Installing Ngrok on Linux or WSL, you will need to download the Ngrok ZIP file from this site: https://ngrok.com/download. Make sure to choose the appropriate to your Linux OS the type of Ngrok file from the `More Options` dropdown menu options. For example, for WSL running Linux 64-bit, choose the `Linux` option which will download you the `ngrok-stable-linux-amd64.zip` file. +Run the following commands in your WSL/Linux terminal to install Ngrok: + +```bash +cd ~ +sudo apt-get unzip +sudo wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip +unzip ngrok-stable-linux-amd64.zip +``` + +Copy the Ngrok file into your `~/.local/bin` folder so it can be accessed from any location. + +To test that Ngrok is installed properly, run the following: + +```bash +ngrok version +``` + +## Setting up Ngrok + +1. Navigate to [Reserved Domains](https://dashboard.ngrok.com/endpoints/domains) in your Ngrok account and reserve a domain. Make sure you select the US region. We will configure Azure and the bot to point to this domain. We'll refer to this domain as `RESERVED_DOMAIN` in this doc. + +2. Now navigate to [TCP Addresses](https://dashboard.ngrok.com/endpoints/tcp-addresses) and reserve a TCP port. Make sure you select the US region. This will be used to push incoming streams to. We'll refer to this port as `RESERVED_PORT` and the full address will be referred to as `RESERVE_FULL_TCP_PORT_ADDRESS`. diff --git a/Samples/PublicSamples/RecordingBot/docs/setup/openssl.md b/Samples/PublicSamples/RecordingBot/docs/setup/openssl.md new file mode 100644 index 000000000..99b287084 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/setup/openssl.md @@ -0,0 +1,18 @@ +# Install OpenSSL + +## Download and Install OpenSSL + +1. Choose the version that applies to your OS from [here](https://slproweb.com/products/Win32OpenSSL.html). As example, I chose the Win64 OpenSSL v1.1.1g MSI (not the light version). +2. Run the EXE or MSI with default settings till completion and that should take care of installing OpenSSL! + +## Add OpenSSL to your PATH + +Why do we want to do this? First off, it’s not a necessity, it just makes it more convenient to use OpenSSL from the command line in the directory of your choice. After the initial install, the openssl.exe is only available from the directory where it resides, namely: `C:\Program Files\OpenSSL-Win64\bin` + +## Test OpenSSL Installation + +Let’s verify that OpenSSL is now accessible from outside its own directory by opening a Command Prompt in an arbitrary location and executing the following command: + +```cmd +openssl version +``` diff --git a/Samples/PublicSamples/RecordingBot/docs/setup/readme.md b/Samples/PublicSamples/RecordingBot/docs/setup/readme.md new file mode 100644 index 000000000..e67f986eb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/setup/readme.md @@ -0,0 +1,6 @@ +# Setup + +1. [Setting up Ngrok](ngrok.md) +2. [Generating SSL Certificate and setting up URL ACL and Certificate Bindings](certificate.md) +3. [Configuring Bot Channel Registration and Granting Permission](bot.md) +4. Optional - [Creating and Assigning Compliance Policy](policy.md) diff --git a/Samples/PublicSamples/RecordingBot/docs/test/end-to-end-testing.md b/Samples/PublicSamples/RecordingBot/docs/test/end-to-end-testing.md new file mode 100644 index 000000000..2c13df5c1 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/test/end-to-end-testing.md @@ -0,0 +1,33 @@ +# End to end testing + +The bot, being deployed to Kubernetes, and interacting with the Teams media services, can be complicated to test, especially when testing multiple callers on the same meeting. Integration testing will also be project specific and the technologies and approaches used can vary. + +This document outlines some ideas using manual testing that could be used to test that the bot is working as expected. They can be used as a starting point to automate testing using the project specific testing technologies. + +In this context testing means running calls and meetings that the bot is connected to. + +## Testing Scenarios + +#### Single call test to check if the bot is recording + +* Run the bot either locally (using Ngrok) or deployed to AKS ensuring the compliance policy has been attached to a user to be used for testing. + +* Create a meeting in Teams (simply add a meeting to the calendar using the test user selected) + +* Join the meeting with the test user. Although it is only a single user 'meeting' it will still trigger the policy and bring the bot into the meeting. You should see the compliance recording banner appear at the top of the Teams Window. Anything spoken into the meeting will be recorded. In order to ensure the full audio stream is recorded, start and end the meeting with a known sequence (like a count up at the start and a countdown at the end). This helps to ensure the full stream has been captured. + +* The call will be recorded and output to ...? + +* A manual check of the recorded content can be conducted by unzipping the output file and using any audio playing application. + +#### Multiple callers on a single bot + +This scenario is similar to the single call test except it will require multiple test users although only a single one requires the compliance policy. Multiple users can be joined to a meeting on a development machine using the browser version of Teams. + +To join multiple users to a call you will either need multiple different browsers using in private windows otherwise logged in sessions between users clash with each other. Microsoft Edge supports user profiles which can be used to create separate profiles for each attending user effectively keeping them separated. + +Alternatively use multiple machines / browsers / people to attend the meeting. If unique content per user is needed then a group of willing test users will be needed (or use automated content injection - see below) + +#### Automation (using a media playback bot to inject content) + +...need some help with this one... diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy-tutorial.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy-tutorial.md new file mode 100644 index 000000000..dffc849de --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy-tutorial.md @@ -0,0 +1,81 @@ +# Deploy Tutorial + +In this tutorial we learn how to deploy the recording bot sample to a new AKS Cluster. We will also +set up a Recording Policy for all users within our Tenant and see the compliance redording banner +in our teams client. + +## Prerequisites + +- [Windows 11](https://www.microsoft.com/software-download/windows11) +- [Powershell 7 as administrator](https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-windows) +- [Git command line tool](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [AZ Azure command line tool](https://learn.microsoft.com/cli/azure/install-azure-cli-windows) +- [Helm command line tool](https://helm.sh/docs/intro/install/) +- [kubectl command line tool](https://kubernetes.io/docs/tasks/tools/install-kubectl-windows/) + - This tutorial also shows how to install kubectl with the azure command line tool +- [Microsoft Entra Id Tenant](https://learn.microsoft.com/entra/fundamentals/create-new-tenant) [with Microsoft Teams users](https://learn.microsoft.com/entra/fundamentals/license-users-groups) +- [Microsoft Azure Subscription](https://learn.microsoft.com/azure/cost-management-billing/manage/create-subscription) + - The subscription in this tutorial is called `recordingbotsubscription`, also see [variables](#variables). +- Microsoft Entra Id adminstrator + +The Microsoft Entra Id administrator is required to create recording policies and to approve +application permissions of the app registration. This tutorial assumes we are a Microsoft Entra Id +administrator and always log in as such unless the tutorial requires otherwise. + +## Contents + +1. [Create an AKS cluster](./deploy/1-aks.md) +2. [Create an Azure Container Registry](./deploy/2-acr.md) +3. [Clone and build recording bot sample](./deploy/3-build.md) +4. [Create and configure Bot Service](./deploy/4-bot-service.md) +5. [Deploy recording sample to AKS cluster](./deploy/5-helm.md) +6. [Create and assign a Recording Policy](./deploy/6-policy.md) +7. [Verify functionality](./deploy/7-test.md) + +## Variables + +Throughout this tutorial we will create azure resources. The names we choose in this tutorial are: + +| Resource | Name | +| ------------------------ | -------------------------------------------------------------- | +| Resource Group | `recordingbottutorial` | +| AKS Cluster | `recordingbotcluster` | +| Azure Container Registry | `recordingbotregistry` | +| App Registration | `recordingbotregistration` | +| Bot Service | `recordingbotservice` | +| Azure Subscription | `recordingbotsubscription` | +| Public IP Address | _pppppppp-pppp-pppp-pppp-pppppppppppp_ | +| Managed Resource Group | _MC__`recordingbottutorial`_`recordingbotcluster`__westeurope_ | + +Variables that are used in this tutorial are: + +| What? | Value | +| --------------------------------------------------- | ------------------------------------------------------- | +| Recording Bot Name | `Tutorial Bot` | +| AKS DNS record | `recordingbottutorial`_.westeurope.cloudapp.azure.com_ | +| App Registration Id | _cccccccc-cccc-cccc-cccc-cccccccccccc_ | +| App Registration Secret | _abcdefghijklmnopqrstuvwxyz_ | +| Recording Policy Name | `TutorialPolicy` | +| Recording Policy Application Instance UPN | `tutorialbot@lm-ag.de` | +| Recording Policy Application Instance Display Name | `Tutorial Bot` | +| Recording Policy Application Instance Object Id | _11111111-1111-1111-1111-111111111111_ | +| Microsoft Entra Id Tenant Id | _99999999-9999-9999-9999-999999999999_ | +| Kubernetes Recording Bot Deployment Name | `recordingbottutorial` | +| Kubernetes Recording Bot Namespace | `recordingbottutorial` | +| Let's Encrypt Email address | `tls-security@lm-ag.de` | +| Windows Nodepool | `win22` | +| Azure Subscription Id | _yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy_ | +| Azure Region | `westeurope` | +| Directory for source code | `C:\Users\User\recordingbottutorial\` | +| Recording Application Docker Container Tag | `recordingbottutorial/application:latest` | +| Public IP of the Public IP Address Resource | _255.255.255.255_ | + +> [!TIP] +> Consider to define own variable values before we start. Keep in mind the Azure resources have +> limitations for naming, read [this](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules) for reference. Some Values are automatically generated and +> can't be changed, but needs to be replaced with you're custom values. + +If you encounter any problems during the tutorial, please feel free to create an [issue](https://github.com/lm-development/aks-sample/issues). +This means that the tutorial can be continuously expanded to include error handling. + +Now let us start to [create an AKS cluster](./deploy/1-aks.md) diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/1-aks.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/1-aks.md new file mode 100644 index 000000000..c40a21148 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/1-aks.md @@ -0,0 +1,778 @@ +# Create an AKS cluster + +Let us start with creating an AKS cluster, on which we will later deploy the containers for the +sample recording bot. Before we can start to run commands in the Azure command line tool, we need +to log in: + +```powershell +az login --tenant 99999999-9999-9999-9999-999999999999 +``` + +After running the command, we see the following message: + +```text +The default web browser has been opened at https://login.microsoftonline.com/99999999-9999-9999-9999-999999999999/oauth2/v2.0/authorize. Please continue the login in the web browser. If no web browser is available or if the web browser fails to open, use device code flow with `az login --use-device-code`. +``` + +A browser window with the Microsoft login page should open, otherwise follow the advice of the output. + +![Login Page](../../images/screenshot-login.png) + +In the browser, we need to log in with our Microsoft Entra Id administrator account and accept the requested scopes. + +After successful login, the output looks similar to this: + +```json +[ + { + "cloudName": "AzureCloud", + "homeTenantId": "99999999-9999-9999-9999-999999999999", + "id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy", + "isDefault": true, + "managedByTenants": [], + "name": "recordingbotsubscription", + "state": "Enabled", + "tenantId": "99999999-9999-9999-9999-999999999999", + "user": { + "name": "user@xyz.com", + "type": "user" + } + } +] +``` + +## Create Azure Resource Group + +Now we can start to create resources in our azure subscription. +Let us start with creating a resource group. + +```powershell +az group create + --location westeurope + --name recordingbottutorial + --subscription "recordingbotsubscription" +``` + +The result in the command line should look something like: + +```json +{ + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/recordingbottutorial", + "location": "westeurope", + "managedBy": null, + "name": "recordingbottutorial", + "properties": { + "provisioningState": "Succeeded" + }, + "tags": null, + "type": "Microsoft.Resources/resourceGroups" +} +``` + +## Create Azure Kubernetes Cluster + +With our Resource Group set up, we create an AKS cluster. For the purpose of this tutorial we will +use the Free Tier. To further reduce costs, we also set the node count of the system nodepool to 1. +Regarding the vm size of the system nodes in the system nodepool we choose the +`standard_d2s_v3`-series, as this series is available at the most regions and some nodes in this +series are available without requesting more quotas. + +> [!WARNING] +> Make sure to check the [pricing tiers](https://learn.microsoft.com/en-us/azure/aks/free-standard-pricing-tiers) +> and [pricing for VMs](https://azure.microsoft.com/en-us/pricing/details/virtual-machines/linux/) +> as costs may incur (even with the Free Tier). If you already have VMs running in your Azure +> subscribtion, also check your [per vm quotas](https://learn.microsoft.com/en-us/azure/quotas/per-vm-quota-requests) +> to avoid exceeding your quotas. + +Here is the command we run to create our AKS cluster resource: + +```powershell +az aks create + --location westeurope + --name recordingbotcluster + --resource-group recordingbottutorial + --tier free + --node-count 1 + --node-vm-size standard_d2s_v3 + --network-plugin azure + --no-ssh-key + --yes + --subscription "recordingbotsubscription" +``` + +After waiting for the command to complete the output in our powershell should look similar to: + +```json +Resource provider 'Microsoft.ContainerService' used by this operation is not registered. We are registering for you. +Registration succeeded. +{ + "aadProfile": null, + "addonProfiles": null, + "agentPoolProfiles": [ + { + "availabilityZones": null, + "capacityReservationGroupId": null, + "count": 1, + "creationData": null, + "currentOrchestratorVersion": "1.28.5", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 30, + "minCount": null, + "mode": "System", + "name": "nodepool1", + "networkProfile": null, + "nodeImageVersion": "AKSUbuntu-2204gen2containerd-202403.25.0", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.28.5", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Ubuntu", + "osType": "Linux", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "scaleDownMode": null, + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "VirtualMachineScaleSets", + "upgradeSettings": { + "drainTimeoutInMinutes": null, + "maxSurge": "10%", + "nodeSoakDurationInMinutes": null + }, + "vmSize": "standard_d2s_v3", + "vnetSubnetId": null, + "workloadRuntime": null + } + ], + "apiServerAccessProfile": null, + "autoScalerProfile": null, + "autoUpgradeProfile": { + "nodeOsUpgradeChannel": "NodeImage", + "upgradeChannel": null + }, + "azureMonitorProfile": null, + "azurePortalFqdn": "recordingb-recordingbottuto-yyyyyy-1585fl53.portal.hcp.westeurope.azmk8s.io", + "currentKubernetesVersion": "1.28.5", + "disableLocalAccounts": false, + "diskEncryptionSetId": null, + "dnsPrefix": "recordingb-recordingbottuto-yyyyyy", + "enablePodSecurityPolicy": null, + "enableRbac": true, + "extendedLocation": null, + "fqdn": "recordingb-recordingbottuto-yyyyyy-1585fl53.hcp.westeurope.azmk8s.io", + "fqdnSubdomain": null, + "httpProxyConfig": null, + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/recordingbottutorial/providers/Microsoft.ContainerService/managedClusters/recordingbotcluster", + "identity": { + "delegatedResources": null, + "principalId": "51d916b6-0106-419f-825b-3d74e292559d", + "tenantId": "99999999-9999-9999-9999-999999999999", + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "identityProfile": { + "kubeletidentity": { + "clientId": "831201f7-171e-45d3-86a9-db8b87ded108", + "objectId": "1aee7386-eb25-4ee0-91f8-cf4bf0641151", + "resourceId": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.ManagedIdentity/userAssignedIdentities/recordingbotcluster-agentpool" + } + }, + "ingressProfile": null, + "kubernetesVersion": "1.28", + "linuxProfile": null, + "location": "westeurope", + "maxAgentPools": 100, + "name": "recordingbotcluster", + "networkProfile": { + "dnsServiceIp": "10.0.0.10", + "ipFamilies": [ + "IPv4" + ], + "loadBalancerProfile": { + "allocatedOutboundPorts": null, + "backendPoolType": "nodeIPConfiguration", + "effectiveOutboundIPs": [ + { + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/publicIPAddresses/pppppppp-pppp-pppp-pppp-pppppppppppp", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope" + } + ], + "enableMultipleStandardLoadBalancers": null, + "idleTimeoutInMinutes": null, + "managedOutboundIPs": { + "count": 1, + "countIpv6": null + }, + "outboundIPs": null, + "outboundIpPrefixes": null + }, + "loadBalancerSku": "standard", + "natGatewayProfile": null, + "networkDataplane": "azure", + "networkMode": null, + "networkPlugin": "azure", + "networkPluginMode": null, + "networkPolicy": null, + "outboundType": "loadBalancer", + "podCidr": null, + "podCidrs": null, + "serviceCidr": "10.0.0.0/16", + "serviceCidrs": [ + "10.0.0.0/16" + ] + }, + "nodeResourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope", + "oidcIssuerProfile": { + "enabled": false, + "issuerUrl": null + }, + "podIdentityProfile": null, + "powerState": { + "code": "Running" + }, + "privateFqdn": null, + "privateLinkResources": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": null, + "resourceGroup": "recordingbottutorial", + "resourceUid": "0123456789abcdef12345678", + "securityProfile": { + "azureKeyVaultKms": null, + "defender": null, + "imageCleaner": null, + "workloadIdentity": null + }, + "serviceMeshProfile": null, + "servicePrincipalProfile": { + "clientId": "msi", + "secret": null + }, + "sku": { + "name": "Base", + "tier": "Free" + }, + "storageProfile": { + "blobCsiDriver": null, + "diskCsiDriver": { + "enabled": true + }, + "fileCsiDriver": { + "enabled": true + }, + "snapshotController": { + "enabled": true + } + }, + "supportPlan": "KubernetesOfficial", + "systemData": null, + "tags": null, + "type": "Microsoft.ContainerService/ManagedClusters", + "upgradeSettings": null, + "windowsProfile": { + "adminPassword": null, + "adminUsername": "azureuser", + "enableCsiProxy": true, + "gmsaProfile": null, + "licenseType": null + }, + "workloadAutoScalerProfile": { + "keda": null, + "verticalPodAutoscaler": null + } +} +``` + +> [!NOTE] +> We notice that the output of the command contains some extra resource we have not requested +> directly. These include a virtual machine scale set, a public IP and more. The extra resources +> have been placed in a newly created resource group. We will need the name of this resource group +> later, so let us search the output and note down its name. We can find it under the property +> `nodeResourceGroup`, among other places. In the provided example output, the resource group is +> called `MC_recordingbottutorial_recordingbotcluster_westeurope`. + +If the command fails with the message: + +```text +unrecognized arguments: --tier free +``` + +the az Azure command line tool is out of date. + +## Add Windows Node Pool + +In the previous section, we created an AKS cluster with a linux node. However, for our recording +bot application, we actually need a windows node instead. Let us create two new windows nodes of +the `standard_d4_v3`-series, this series is also available in most Microsoft Azure regions +and meets the requirement of two physical cpu cores specified by the media SDK: + +```powershell +az aks nodepool add + --cluster-name recordingbotcluster + --name win22 + --resource-group recordingbottutorial + --node-vm-size standard_d4_v3 + --node-count 2 + --os-type Windows + --os-sku Windows2022 + --subscription "recordingbotsubscription" +``` + +This command also needs some time to complete, our result output should look similar to: + +```json +{ + "availabilityZones": null, + "capacityReservationGroupId": null, + "count": 2, + "creationData": null, + "currentOrchestratorVersion": "1.28.5", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/recordingbottutorial/providers/Microsoft.ContainerService/managedClusters/recordingbotcluster/agentPools/win22", + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 30, + "minCount": null, + "mode": "User", + "name": "win22", + "networkProfile": null, + "nodeImageVersion": "AKSWindows-2022-containerd-20348.2340.240401", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.28.5", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Windows2022", + "osType": "Windows", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "resourceGroup": "recordingbottutorial", + "scaleDownMode": "Delete", + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "Microsoft.ContainerService/managedClusters/agentPools", + "typePropertiesType": "VirtualMachineScaleSets", + "upgradeSettings": { + "drainTimeoutInMinutes": null, + "maxSurge": null, + "nodeSoakDurationInMinutes": null + }, + "vmSize": "standard_d4_v3", + "vnetSubnetId": null, + "workloadRuntime": null +} +``` + +Now our AKS cluster has 1 linux system node and 2 windows nodes for our recording application. + +## Untaint system nodepool + +Later we need to run some applications on our system nodes. But sometimes the system nodes have +_taints_ ([What are taints?](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)) that don't allow scheduling new pods, that means we can't run our +applications on the nodes. + +### Check if system nodepool is tainted + +So let's first check if our system nodepool has taints that would cause problems when scheduling. + +```powershell +az aks nodepool list + --cluster-name recordingbotcluster + --resource-group recordingbottutorial + --subscription "recordingbotsubscription" | Select-String + -Pattern 'name','nodeTaints','{','}','[',']','mode','NoSchedule' + -SimpleMatch + -NoEmphasis +``` + +The result of this should look like + +```json +[ + { + "mode": "System", + "name": "nodepool1", + "nodeTaints": [ + "CriticalAddonsOnly=true:NoSchedule" + ], + "powerState": { + }, + "scaleDownMode": null, + "upgradeSettings": { + }, + }, + { + "mode": "User", + "name": "win22", + "nodeTaints": null, + "powerState": { + }, + "scaleDownMode": "Delete", + "upgradeSettings": { + }, + } +] +``` + +The result will contain a list of nodepools of our aks cluster. To identify which nodepool we are +looking at, search for the _name_ field in the entry. In the example, we have a windows nodepool, +called `win22` and a system nodepool, called `nodepool1`. + +> [!Note] +> The _mode_ field of `nodepool1` shows `System` while the _mode_ field of the `win22` nodepool +> shows `User`. + +To check if our system nodepool `nodepool1` is tainted check the _nodeTaints_ field. If the field +has a value of `null` we can continue with [setting the DNS name](#set-dns-name). If it's not the +case, like in our case, we have to untaint the nodepool. If your system nodepool has a different +name replace `nodepool1` with the name you see in your previous result. + +```powershell +az aks nodepool update + --cluster-name recordingbotcluster + --name nodepool1 + --resource-group recordingbottutorial + --subscription "recordingbotsubscription" + --% + --node-taints "" +``` + +The result of the command should look similar to: + +```json +{ + "availabilityZones": null, + "capacityReservationGroupId": null, + "count": 1, + "creationData": null, + "currentOrchestratorVersion": "1.28.5", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/recordingbottutorial/providers/Microsoft.ContainerService/managedClusters/recordingbotcluster/agentPools/nodepool1", + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 30, + "minCount": null, + "mode": "System", + "name": "nodepool1", + "networkProfile": null, + "nodeImageVersion": "AKSUbuntu-2204gen2containerd-202403.25.0", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.28.5", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Ubuntu", + "osType": "Linux", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "resourceGroup": "recordingbottutorial", + "scaleDownMode": null, + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "Microsoft.ContainerService/managedClusters/agentPools", + "typePropertiesType": "VirtualMachineScaleSets", + "upgradeSettings": { + "drainTimeoutInMinutes": null, + "maxSurge": "10%", + "nodeSoakDurationInMinutes": null + }, + "vmSize": "standard_d2s_v3", + "vnetSubnetId": null, + "workloadRuntime": null +} +``` + +As you can see the _nodeTaints_ field now updated to `null`. + +## Set DNS name + +When we created our AKS cluster, a public IP was automatically created for us. To use this IP +address, we want to create a DNS entry so we can reference it by name. Since the IP address was +automatically created for us, it resides in the automatically created resource group of managed +resources. If we didn't note down the name of this resource group in the +[Create Azure Kubernetes Cluster](#create-azure-kubernetes-cluster) step, follow the next step to find out the name. Otherwise we +can skip ahead to [getting the public IP resource name](#get-the-public-ip-resource). + +### Get the managed resources resource group name + +We can find the managed resource group in the description of the AKS cluster: + +```powershell +az aks show + --resource-group recordingbottutorial + --name recordingbotcluster + --subscription "recordingbotsubscription" | Select-String + -Pattern 'nodeResourceGroup' + -SimpleMatch + -NoEmphasis +``` + +This will give us the response + +```json + "nodeResourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope", +``` + +The IP address resource was created in the Azure managed resource group which is used by Azure to +manage the AKS cluster. You can find the managed resource group name in the _nodeResourceGroup_ +field of the output. In the example output the resource group is called +`MC_recordingbottutorial_recordingbotcluster_westeurope`. + +### Get the public IP resource + +Since the public IP address is an Azure resource, it can be referenced by name. To find out, what +name was assigned to it, we execute the following command: + +```powershell +az network public-ip list + --resource-group MC_recordingbottutorial_recordingbotcluster_westeurope + --subscription "recordingbotsubscription" +``` + +We consider our example output: + +```json +[ + { + "ddosSettings": { + "protectionMode": "VirtualNetworkInherited" + }, + "etag": "W/\"f5508f05-0e65-479a-9399-d436f02e0a66\"", + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/publicIPAddresses/pppppppp-pppp-pppp-pppp-pppppppppppp", + "idleTimeoutInMinutes": 4, + "ipAddress": "255.255.255.255", + "ipConfiguration": { + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/pppppppp-pppp-pppp-pppp-pppppppppppp", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope" + }, + "ipTags": [], + "location": "westeurope", + "name": "pppppppp-pppp-pppp-pppp-pppppppppppp", + "provisioningState": "Succeeded", + "publicIPAddressVersion": "IPv4", + "publicIPAllocationMethod": "Static", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope", + "resourceGuid": "539f439e-5c50-4bc6-a4a9-fb2d102e88f3", + "sku": { + "name": "Standard", + "tier": "Regional" + }, + "tags": { + "aks-managed-cluster-name": "recordingbotcluster", + "aks-managed-cluster-rg": "recordingbottutorial", + "aks-managed-type": "aks-slb-managed-outbound-ip" + }, + "type": "Microsoft.Network/publicIPAddresses", + "zones": [ + "3", + "1", + "2" + ] + } +] +``` + +The name of the public IP Address resource can be found in the _name_ field of the output. In our +example output the name of the public IP address resource is `pppppppp-pppp-pppp-pppp-pppppppppppp`. +Also write down the value of the _ipAddress_ field as we need the the address when we deploy the sample +to our aks cluster, in our example output the ipAddress is `255.255.255.255`. + +### Set DNS name for public IP resource + +With the managed resource group name and the public IP resource name, we can now set a DNS name for +the public IP resource with the command: + +```powershell +az network public-ip update + --resource-group MC_recordingbottutorial_recordingbotcluster_westeurope + --name pppppppp-pppp-pppp-pppp-pppppppppppp + --dns-name recordingbottutorial + --subscription "recordingbotsubscription" +``` + +Do not forget to replace the DNS name with your own AKS DNS record. We do not use the fully +qualified name. So in our example, for the DNS entry +`recordingbottutorial`_.westeurope.cloudapp.azure.com_ we supply the parameter `--dns-name` +only with `recordingbottutorial`. + +> [!WARNING] +> The DNS record that is created from our custom part and the postfix must be globally unique. + +If the command completed successful the result will look similar to this: + +```json +{ + "ddosSettings": { + "protectionMode": "VirtualNetworkInherited" + }, + "dnsSettings": { + "domainNameLabel": "recordingbottutorial", + "fqdn": "recordingbottutorial.westeurope.cloudapp.azure.com" + }, + "etag": "W/\"dc9d1467-e11a-46fa-bf7d-ad60a7713c7f\"", + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/publicIPAddresses/pppppppp-pppp-pppp-pppp-pppppppppppp", + "idleTimeoutInMinutes": 4, + "ipAddress": "108.141.184.42", + "ipConfiguration": { + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/loadBalancers/kubernetes/frontendIPConfigurations/pppppppp-pppp-pppp-pppp-pppppppppppp", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope" + }, + "ipTags": [], + "location": "westeurope", + "name": "pppppppp-pppp-pppp-pppp-pppppppppppp", + "provisioningState": "Succeeded", + "publicIPAddressVersion": "IPv4", + "publicIPAllocationMethod": "Static", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope", + "resourceGuid": "539f439e-5c50-4bc6-a4a9-fb2d102e88f3", + "sku": { + "name": "Standard", + "tier": "Regional" + }, + "tags": { + "aks-managed-cluster-name": "recordingbotcluster", + "aks-managed-cluster-rg": "recordingbottutorial", + "aks-managed-type": "aks-slb-managed-outbound-ip" + }, + "type": "Microsoft.Network/publicIPAddresses", + "zones": [ + "3", + "1", + "2" + ] +} +``` + +As we can see, the field _dnsSettings_ was added to the resource. Within the DNS settings object +(the field _dnsSettings_) we can see the custom part of our DNS record (field _domainNameLabel_) +and the fully qualified domain name (field _fqdn_) with the postfix provided by Azure. + +If the fqdn already exists we would get the follwing error message. + +```text +(DnsRecordInUse) DNS record recordingbottutorial.westeurope.cloudapp.azure.com is already used byanother public IP. +Code: DnsRecordInUse +Message: DNS record recordingbottutorial.westeurope.cloudapp.azure.com is already used by another public IP. +``` + +then we would have to come up with a new prefix for the DNS record. + +## Install kubectl tool + +If we have the tool already installed we can skip this part and [get the credentials for our aks cluster](#get-aks-credentials). + +We can install kubectl with the Azure command line tool: + +```powershell +az aks install-cli +``` + +The resulting output should look similar to + +```text +The detected architecture of current device is "amd64", and the binary for "amd64" will be downloaded. If the detection is wrong, please download and install the binary corresponding to the appropriate architecture. +No version specified, will get the latest version of kubectl from "https://storage.googleapis.com/kubernetes-release/release/stable.txt" +Downloading client to "C:\Users\User\.azure-kubectl\kubectl.exe" from "https://storage.googleapis.com/kubernetes-release/release/v1.29.4/bin/windows/amd64/kubectl.exe" +No version specified, will get the latest version of kubelogin from "https://api.github.com/repos/Azure/kubelogin/releases/latest" +Downloading client to "C:\Users\User\AppData\Local\Temp\tmp56tfm9jk\kubelogin.zip" from "https://github.com/Azure/kubelogin/releases/download/v0.1.1/kubelogin.zip" +Moving binary to "C:\Users\User\.azure-kubelogin\kubelogin.exe" from "C:\Users\User\AppData\Local\Temp\tmp56tfm9jk\bin\windows_amd64\kubelogin.exe" +``` + +> [!NOTE] +> After installing kubectl on our kubernetes cluster, we might need to open a new powershell +> session and may need to log in again as described in [Create an AKS cluster](#create-an-aks-cluster). + +## Get AKS credentials + +To deploy resources to our AKS cluster and do the operations with the kubernetes command line +tool (kubectl), we need access to our cluster. We can get the credentials for kubectl with the +azure command line tool: + +```powershell +az aks get-credentials + --name recordingbotcluster + --resource-group recordingbottutorial + --subscription "recordingbotsubscription" +``` + +A successful result will look like: + +```text +Merged "recordingbotcluster" as current context in C:\Users\User\.kube\config +``` + +To test if it really was successful, we will try to list the nodes in our kubernetes cluster with kubectl + +```powershell +kubectl get nodes +``` + +If the run was successful the result will look like: + +```text +NAME STATUS ROLES AGE VERSION +aks-nodepool1-18840134-vmss000001 Ready agent 4h31m v1.28.5 +akswin22000002 Ready agent 4h29m v1.28.5 +akswin22000003 Ready agent 4h29m v1.28.5 +``` + +> [!TIP] +> If you experience problems with kubectl and did not install kubectl with the azure command line +> tool, try to [install kubectl with azure command line tool](#install-kubectl-tool). + +As a small summary we now: + +- created an AKS cluster +- added a windows nodepool with 2 nodes +- made sure we can use the system nodes +- set up a DNS record into the cluster +- retrieved the credentials for deployments to our AKS cluster + +Next we can [create an Azure container registry](./2-acr.md). diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/2-acr.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/2-acr.md new file mode 100644 index 000000000..6f30774e8 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/2-acr.md @@ -0,0 +1,360 @@ +# Create an Azure Container Registry + +After we successfully [created an AKS cluster](./1-aks.md), we also need a container registry. In the +container registry we will later build and store the docker images of the recording bot +application. The AKS cluster will pull the docker images from the registry, distributes and starts +them on the nodes of the AKS cluster. + +## Create Azure Container Registry + +Let us create the Azure container registry, we will use the Basic SKU of the Azure container registry. + +```powershell +az acr create + --resource-group recordingbottutorial + --name recordingbotregistry + --sku Basic + --location westeurope + --subscription "recordingbotsubscription" +``` + +The completion of the command takes some time and gives us an output similar to + +```json +Resource provider 'Microsoft.ContainerRegistry' used by this operation is not registered. We are registering for you. +Registration succeeded. +{ + "adminUserEnabled": false, + "anonymousPullEnabled": false, + "creationDate": "2024-04-17T12:52:33.408458+00:00", + "dataEndpointEnabled": false, + "dataEndpointHostNames": [], + "encryption": { + "keyVaultProperties": null, + "status": "disabled" + }, + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/recordingbottutorial/providers/Microsoft.ContainerRegistry/registries/recordingbotregistry", + "identity": null, + "location": "westeurope", + "loginServer": "recordingbotregistry.azurecr.io", + "metadataSearch": "Disabled", + "name": "recordingbotregistry", + "networkRuleBypassOptions": "AzureServices", + "networkRuleSet": null, + "policies": { + "azureAdAuthenticationAsArmPolicy": { + "status": "enabled" + }, + "exportPolicy": { + "status": "enabled" + }, + "quarantinePolicy": { + "status": "disabled" + }, + "retentionPolicy": { + "days": 7, + "lastUpdatedTime": "2024-04-17T12:52:45.335053+00:00", + "status": "disabled" + }, + "softDeletePolicy": { + "lastUpdatedTime": "2024-04-17T12:52:45.335094+00:00", + "retentionDays": 7, + "status": "disabled" + }, + "trustPolicy": { + "status": "disabled", + "type": "Notary" + } + }, + "privateEndpointConnections": [], + "provisioningState": "Succeeded", + "publicNetworkAccess": "Enabled", + "resourceGroup": "recordingbottutorial", + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "status": null, + "systemData": { + "createdAt": "2024-04-17T12:52:33.408458+00:00", + "createdBy": "user@xyz.com", + "createdByType": "User", + "lastModifiedAt": "2024-04-17T12:52:33.408458+00:00", + "lastModifiedBy": "user@xyz.com", + "lastModifiedByType": "User" + }, + "tags": {}, + "type": "Microsoft.ContainerRegistry/registries", + "zoneRedundancy": "Disabled" +} +``` + +## Attach Container Registry to AKS cluster + +Next we will attach the container registry to our AKS cluster, +then our AKS cluster can pull docker images from the container registry. + +```powershell +az aks update + --name recordingbotcluster + --resource-group recordingbottutorial + --attach-acr recordingbotregistry + --subscription "recordingbotsubscription" +``` + +The execution of the command should first show us that some `AAD role propagation` is in process. +After that the execution of the command takes some time and results in an output similar to: + +```json +{ + "aadProfile": null, + "addonProfiles": null, + "agentPoolProfiles": [ + { + "availabilityZones": null, + "capacityReservationGroupId": null, + "count": 1, + "creationData": null, + "currentOrchestratorVersion": "1.28.5", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 30, + "minCount": null, + "mode": "System", + "name": "nodepool1", + "networkProfile": null, + "nodeImageVersion": "AKSUbuntu-2204gen2containerd-202403.25.0", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.28.5", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Ubuntu", + "osType": "Linux", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "scaleDownMode": null, + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "VirtualMachineScaleSets", + "upgradeSettings": { + "drainTimeoutInMinutes": null, + "maxSurge": "10%", + "nodeSoakDurationInMinutes": null + }, + "vmSize": "standard_d2s_v3", + "vnetSubnetId": null, + "workloadRuntime": null + }, + { + "availabilityZones": null, + "capacityReservationGroupId": null, + "count": 2, + "creationData": null, + "currentOrchestratorVersion": "1.28.5", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 30, + "minCount": null, + "mode": "User", + "name": "win22", + "networkProfile": null, + "nodeImageVersion": "AKSWindows-2022-containerd-20348.2340.240401", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.28.5", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Windows2022", + "osType": "Windows", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "scaleDownMode": "Delete", + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "VirtualMachineScaleSets", + "upgradeSettings": { + "drainTimeoutInMinutes": null, + "maxSurge": null, + "nodeSoakDurationInMinutes": null + }, + "vmSize": "standard_d4_v3", + "vnetSubnetId": null, + "windowsProfile": {}, + "workloadRuntime": null + } + ], + "apiServerAccessProfile": null, + "autoScalerProfile": null, + "autoUpgradeProfile": { + "nodeOsUpgradeChannel": "NodeImage", + "upgradeChannel": null + }, + "azureMonitorProfile": null, + "azurePortalFqdn": "recordingb-recordingbottuto-yyyyyy-1585fl53.portal.hcp.westeurope.azmk8s.io", + "currentKubernetesVersion": "1.28.5", + "disableLocalAccounts": false, + "diskEncryptionSetId": null, + "dnsPrefix": "recordingb-recordingbottuto-yyyyyy", + "enablePodSecurityPolicy": null, + "enableRbac": true, + "extendedLocation": null, + "fqdn": "recordingb-recordingbottuto-yyyyyy-1585fl53.hcp.westeurope.azmk8s.io", + "fqdnSubdomain": null, + "httpProxyConfig": null, + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/recordingbottutorial/providers/Microsoft.ContainerService/managedClusters/recordingbotcluster", + "identity": { + "delegatedResources": null, + "principalId": "51d916b6-0106-419f-825b-3d74e292559d", + "tenantId": "99999999-9999-9999-9999-999999999999", + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "identityProfile": { + "kubeletidentity": { + "clientId": "831201f7-171e-45d3-86a9-db8b87ded108", + "objectId": "1aee7386-eb25-4ee0-91f8-cf4bf0641151", + "resourceId": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourcegroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.ManagedIdentity/userAssignedIdentities/recordingbotcluster-agentpool" + } + }, + "ingressProfile": null, + "kubernetesVersion": "1.28", + "linuxProfile": null, + "location": "westeurope", + "maxAgentPools": 100, + "name": "recordingbotcluster", + "networkProfile": { + "dnsServiceIp": "10.0.0.10", + "ipFamilies": [ + "IPv4" + ], + "loadBalancerProfile": { + "allocatedOutboundPorts": null, + "backendPoolType": "nodeIPConfiguration", + "effectiveOutboundIPs": [ + { + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/MC_recordingbottutorial_recordingbotcluster_westeurope/providers/Microsoft.Network/publicIPAddresses/cab190bb-ec74-478e-b7f1-b36c83bfa94e", + "resourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope" + } + ], + "enableMultipleStandardLoadBalancers": null, + "idleTimeoutInMinutes": null, + "managedOutboundIPs": { + "count": 1, + "countIpv6": null + }, + "outboundIPs": null, + "outboundIpPrefixes": null + }, + "loadBalancerSku": "standard", + "natGatewayProfile": null, + "networkDataplane": "azure", + "networkMode": null, + "networkPlugin": "azure", + "networkPluginMode": null, + "networkPolicy": null, + "outboundType": "loadBalancer", + "podCidr": null, + "podCidrs": null, + "serviceCidr": "10.0.0.0/16", + "serviceCidrs": [ + "10.0.0.0/16" + ] + }, + "nodeResourceGroup": "MC_recordingbottutorial_recordingbotcluster_westeurope", + "oidcIssuerProfile": { + "enabled": false, + "issuerUrl": null + }, + "podIdentityProfile": null, + "powerState": { + "code": "Running" + }, + "privateFqdn": null, + "privateLinkResources": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": null, + "resourceGroup": "recordingbottutorial", + "resourceUid": "0123456789abcdef12345678", + "securityProfile": { + "azureKeyVaultKms": null, + "defender": null, + "imageCleaner": null, + "workloadIdentity": null + }, + "serviceMeshProfile": null, + "servicePrincipalProfile": { + "clientId": "msi", + "secret": null + }, + "sku": { + "name": "Base", + "tier": "Free" + }, + "storageProfile": { + "blobCsiDriver": null, + "diskCsiDriver": { + "enabled": true + }, + "fileCsiDriver": { + "enabled": true + }, + "snapshotController": { + "enabled": true + } + }, + "supportPlan": "KubernetesOfficial", + "systemData": null, + "tags": null, + "type": "Microsoft.ContainerService/ManagedClusters", + "upgradeSettings": null, + "windowsProfile": { + "adminPassword": null, + "adminUsername": "azureuser", + "enableCsiProxy": true, + "gmsaProfile": null, + "licenseType": null + }, + "workloadAutoScalerProfile": { + "keda": null, + "verticalPodAutoscaler": null + } +} +``` + +We successfully attached our Azure container registry to our AKS cluster +and it can pull docker images from our Azure container registry. + +In the next step we [clone the code and build our docker image in the container registry](./3-build.md) diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/3-build.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/3-build.md new file mode 100644 index 000000000..af2024754 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/3-build.md @@ -0,0 +1,462 @@ +# Clone and build recording bot sample + +Next let us clone the source code for the recording bot sample, from +the source code we will build the application in a Docker container. + +## Clone the sample + +To clone the sample we first navigate to the directory we want to clone the code to: + +```powershell +cd C:\Users\User\recordingbottutorial +``` + +> [!TIP] +> Make sure the directory existists beforehand, to create a directory run `mkdir C:\Users\User\recordingbottutorial`. + +Next we use git to clone the source code from github: + +```powershell +git clone https://github.com/LM-Development/aks-sample.git +``` + +The execution takes some time to download, the repository with all samples is downloaded, +and the output should look similar to: + +```text +Cloning into 'aks-sample'... +remote: Enumerating objects: 10526, done. +remote: Counting objects: 100% (3138/3138), done. +remote: Compressing objects: 100% (1167/1167), done. +remote: Total 10526 (delta 2336), reused 2427 (delta 1912), pack-reused 7388 +Receiving objects: 100% (10526/10526), 207.10 MiB | 10.09 MiB/s, done. +Resolving deltas: 100% (8606/8606), done. +Updating files: 100% (1289/1289), done. +``` + +Now we navigate to the aks sample in the repository we just downloaded. + +```powershell +cd .\aks-sample\Samples\PublicSamples\RecordingBot\ +``` + +## Build the application + +To build the application we will push the dockerfile and the source code of the AKS sample to our +Azure container registry. The registry will build the application into a container and stores the +container in the registry. To do so we also have to provide the build job with the tag we want to +have for our container (`-t`-parameter): + +```powershell +az acr build + --registry recordingbotregistry + --resource-group recordingbottutorial + -t recordingbottutorial/application:latest + --platform Windows + --file ./build/Dockerfile + --subscription "recordingbotsubscription" + . +``` + +The build of the Docker container takes a very long time. The source code is first uploaded and then +a quite large windows container starts building before the app is built. +However the complete output should look similar to: + +```text +Packing source code into tar to upload... +Excluding '.gitignore' based on default ignore rules +Uploading archived source code from 'C:\Users\FKA\AppData\Local\Temp\build_archive_062a5cd756dc416e81d889bca3b223f5.tar.gz'... +D:\a\_work\1\s\build_scripts\windows\artifacts\cli\Lib\site-packages\cryptography/hazmat/backends/openssl/backend.py:17: UserWarning: You are using cryptography on a 32-bit Python on a 64-bit Windows Operating System. Cryptography will be significantly faster if you switch to using a 64-bit Python. +Sending context (79.930 MiB) to registry: recordingbotregistry... +Queued a build with ID: cb1 +Waiting for an agent... +2024/04/22 13:10:09 Downloading source code... +2024/04/22 13:10:16 Finished downloading source code +2024/04/22 13:10:18 Using acb_vol_b0ea293c-940e-44ed-b386-a0ecc9e0ec89 as the home volume +2024/04/22 13:10:19 Setting up Docker configuration... +2024/04/22 13:10:27 Successfully set up Docker configuration +2024/04/22 13:10:27 Logging in to registry: recordingbotregistry.azurecr.io +2024/04/22 13:10:31 Successfully logged into recordingbotregistry.azurecr.io +2024/04/22 13:10:31 Executing step ID: build. Timeout(sec): 28800, Working directory: '', Network: '' +2024/04/22 13:10:31 Scanning for dependencies... +2024/04/22 13:10:35 Successfully scanned dependencies +2024/04/22 13:10:35 Launching container with name: build +Sending build context to Docker daemon 87.51MB +Step 1/20 : FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS build +8.0-windowsservercore-ltsc2022: Pulling from dotnet/sdk +7c76e5cf7755: Already exists +197484daab96: Pulling fs layer +f2260092360a: Pulling fs layer +12604b42eee2: Pulling fs layer +a56b46197d8a: Pulling fs layer +40de75951aee: Pulling fs layer +675965b9e219: Pulling fs layer +d63d65ec8653: Pulling fs layer +fbeeb0f49213: Pulling fs layer +4295a548cbb3: Pulling fs layer +e9692349cfe4: Pulling fs layer +d506a2a67773: Pulling fs layer +a56b46197d8a: Waiting +40de75951aee: Waiting +675965b9e219: Waiting +d63d65ec8653: Waiting +fbeeb0f49213: Waiting +4295a548cbb3: Waiting +e9692349cfe4: Waiting +d506a2a67773: Waiting +f2260092360a: Verifying Checksum +f2260092360a: Download complete +a56b46197d8a: Download complete +40de75951aee: Verifying Checksum +40de75951aee: Download complete +12604b42eee2: Verifying Checksum +12604b42eee2: Download complete +675965b9e219: Verifying Checksum +675965b9e219: Download complete +d63d65ec8653: Verifying Checksum +d63d65ec8653: Download complete +fbeeb0f49213: Verifying Checksum +fbeeb0f49213: Download complete +e9692349cfe4: Verifying Checksum +e9692349cfe4: Download complete +d506a2a67773: Verifying Checksum +d506a2a67773: Download complete +4295a548cbb3: Verifying Checksum +4295a548cbb3: Download complete +197484daab96: Verifying Checksum +197484daab96: Download complete +197484daab96: Pull complete +f2260092360a: Pull complete +12604b42eee2: Pull complete +a56b46197d8a: Pull complete +40de75951aee: Pull complete +675965b9e219: Pull complete +d63d65ec8653: Pull complete +fbeeb0f49213: Pull complete +4295a548cbb3: Pull complete +e9692349cfe4: Pull complete +d506a2a67773: Pull complete +Digest: sha256:ce3009d6cb2c647ae0e1bb8cc984d643611ced83704b1c1f331178853e5d7e7d +Status: Downloaded newer image for mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 + ---> dd178c759d24 +Step 2/20 : ARG CallSignalingPort=9441 + ---> Running in 363dc8870d49 +Removing intermediate container 363dc8870d49 + ---> dc70870cd8c8 +Step 3/20 : ARG CallSignalingPort2=9442 + ---> Running in e661813679b5 +Removing intermediate container e661813679b5 + ---> b07121138db6 +Step 4/20 : ARG InstanceInternalPort=8445 + ---> Running in ab423a8d39f0 +Removing intermediate container ab423a8d39f0 + ---> 850b8862d20c +Step 5/20 : COPY /src /src + ---> 1370e31c2d05 +Step 6/20 : WORKDIR /src/RecordingBot.Console + ---> Running in ae57b889db82 +Removing intermediate container ae57b889db82 + ---> d5ae5532d288 +Step 7/20 : RUN dotnet build RecordingBot.Console.csproj --arch x64 --self-contained --configuration Release --output C:\app + ---> Running in b1dd2e2f9ec5 + +MSBuild version 17.9.8+b34f75857 for .NET + Determining projects to restore... + Restored C:\src\RecordingBot.Services\RecordingBot.Services.csproj (in 42.65 sec). + Restored C:\src\RecordingBot.Model\RecordingBot.Model.csproj (in 42.65 sec). + Restored C:\src\RecordingBot.Console\RecordingBot.Console.csproj (in 151 ms). + + RecordingBot.Model -> C:\app\RecordingBot.Model.dll + RecordingBot.Services -> C:\app\RecordingBot.Services.dll + RecordingBot.Console -> C:\app\RecordingBot.Console.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:01:28.54 + +Removing intermediate container b1dd2e2f9ec5 + ---> 45573b270fe0 +Step 8/20 : FROM mcr.microsoft.com/windows/server:ltsc2022 +ltsc2022: Pulling from windows/server +7d7d659851e2: Pulling fs layer +0e72f557f0f3: Pulling fs layer +0e72f557f0f3: Verifying Checksum +0e72f557f0f3: Download complete +7d7d659851e2: Verifying Checksum +7d7d659851e2: Download complete +7d7d659851e2: Pull complete +0e72f557f0f3: Pull complete +Digest: sha256:f2a7ad9732bdaf680bcadb270101f1908cf9969581b094c3279f1481eb181a71 +Status: Downloaded newer image for mcr.microsoft.com/windows/server:ltsc2022 + ---> 38f56eb00da7 +Step 9/20 : SHELL ["powershell", "-Command"] + ---> Running in 8f5b3b9696c3 +Removing intermediate container 8f5b3b9696c3 + ---> 4f412d93e1e2 +Step 10/20 : ADD https://aka.ms/vs/17/release/vc_redist.x64.exe /bot/VC_redist.x64.exe + + + ---> 039bdabdcd43 +Step 11/20 : COPY /scripts/entrypoint.cmd /bot + ---> c79f6cd07136 +Step 12/20 : COPY /scripts/halt_termination.ps1 /bot + ---> 624da28ae9b6 +Step 13/20 : COPY --from=build /app /bot + ---> b2d03bb4edfa +Step 14/20 : WORKDIR /bot + ---> Running in 706ec3610b4d +Removing intermediate container 706ec3610b4d + ---> a2138a17a902 +Step 15/20 : RUN Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) + ---> Running in 6bf19d935028 + +Forcing web requests to allow TLS v1.2 (Required for requests to Chocolatey.org) +Getting latest version of the Chocolatey package for download. +Not using proxy. +Getting Chocolatey from https://community.chocolatey.org/api/v2/package/chocolatey/2.2.2. +Downloading https://community.chocolatey.org/api/v2/package/chocolatey/2.2.2 to C:\Users\ContainerAdministrator\AppData\Local\Temp\chocolatey\chocoInstall\chocolatey.zip +Not using proxy. +Extracting C:\Users\ContainerAdministrator\AppData\Local\Temp\chocolatey\chocoInstall\chocolatey.zip to C:\Users\ContainerAdministrator\AppData\Local\Temp\chocolatey\chocoInstall +Installing Chocolatey on the local machine +Creating ChocolateyInstall as an environment variable (targeting 'Machine') + Setting ChocolateyInstall to 'C:\ProgramData\chocolatey' +WARNING: It's very likely you will need to close and reopen your shell + before you can use choco. +Restricting write permissions to Administrators +We are setting up the Chocolatey package repository. +The packages themselves go to 'C:\ProgramData\chocolatey\lib' + (i.e. C:\ProgramData\chocolatey\lib\yourPackageName). +A shim file for the command line goes to 'C:\ProgramData\chocolatey\bin' + and points to an executable in 'C:\ProgramData\chocolatey\lib\yourPackageName'. + +Creating Chocolatey folders if they do not already exist. + +chocolatey.nupkg file not installed in lib. + Attempting to locate it from bootstrapper. +PATH environment variable does not have C:\ProgramData\chocolatey\bin in it. Adding... +WARNING: Not setting tab completion: Profile file does not exist at +'C:\Users\ContainerAdministrator\Documents\WindowsPowerShell\Microsoft.PowerShe +ll_profile.ps1'. +Chocolatey (choco.exe) is now ready. +You can call choco from anywhere, command line or powershell by typing choco. +Run choco /? for a list of functions. +You may need to shut down and restart powershell and/or consoles + first prior to using choco. +Ensuring Chocolatey commands are on the path +Ensuring chocolatey.nupkg is in the lib folder +Removing intermediate container 6bf19d935028 + ---> 94b203190fa7 +Step 16/20 : RUN choco install openssl.light -y + ---> Running in c6ba27fdfc95 + +Chocolatey v2.2.2 +Installing the following packages: +openssl.light +By installing, you accept licenses for the packages. +Progress: Downloading chocolatey-compatibility.extension 1.0.0... 100% + +chocolatey-compatibility.extension v1.0.0 [Approved] +chocolatey-compatibility.extension package files install completed. Performing other installation steps. + Installed/updated chocolatey-compatibility extensions. + The install of chocolatey-compatibility.extension was successful. + Software installed to 'C:\ProgramData\chocolatey\extensions\chocolatey-compatibility' +Progress: Downloading chocolatey-core.extension 1.4.0... 100% + +chocolatey-core.extension v1.4.0 [Approved] +chocolatey-core.extension package files install completed. Performing other installation steps. + Installed/updated chocolatey-core extensions. + The install of chocolatey-core.extension was successful. + Software installed to 'C:\ProgramData\chocolatey\extensions\chocolatey-core' +Progress: Downloading chocolatey-windowsupdate.extension 1.0.5... 100% + +chocolatey-windowsupdate.extension v1.0.5 [Approved] +chocolatey-windowsupdate.extension package files install completed. Performing other installation steps. + Installed/updated chocolatey-windowsupdate extensions. + The install of chocolatey-windowsupdate.extension was successful. + Software installed to 'C:\ProgramData\chocolatey\extensions\chocolatey-windowsupdate' +Progress: Downloading KB2919442 1.0.20160915... 100% + +KB2919442 v1.0.20160915 [Approved] +KB2919442 package files install completed. Performing other installation steps. +Skipping installation because this hotfix only applies to Windows 8.1 and Windows Server 2012 R2. + The install of KB2919442 was successful. + Software install location not explicitly set, it could be in package or + default install location of installer. +Progress: Downloading KB2919355 1.0.20160915... 100% + +KB2919355 v1.0.20160915 [Approved] +KB2919355 package files install completed. Performing other installation steps. +Skipping installation because this hotfix only applies to Windows 8.1 and Windows Server 2012 R2. + The install of KB2919355 was successful. + Software install location not explicitly set, it could be in package or + default install location of installer. +Progress: Downloading KB2999226 1.0.20181019... 100% + +KB2999226 v1.0.20181019 [Approved] - Possibly broken +KB2999226 package files install completed. Performing other installation steps. +Skipping installation because update KB2999226 does not apply to this operating system (Microsoft Windows Server 2022 Datacenter). + The install of KB2999226 was successful. + Software install location not explicitly set, it could be in package or + default install location of installer. +Progress: Downloading KB3035131 1.0.3... 100% + +KB3035131 v1.0.3 [Approved] +KB3035131 package files install completed. Performing other installation steps. +Skipping installation because update KB3035131 does not apply to this operating system (Microsoft Windows Server 2022 Datacenter). + The install of KB3035131 was successful. + Software install location not explicitly set, it could be in package or + default install location of installer. +Progress: Downloading KB3033929 1.0.5... 100% + +KB3033929 v1.0.5 [Approved] +KB3033929 package files install completed. Performing other installation steps. +Skipping installation because update KB3033929 does not apply to this operating system (Microsoft Windows Server 2022 Datacenter). + The install of KB3033929 was successful. + Software install location not explicitly set, it could be in package or + default install location of installer. +Progress: Downloading vcredist140 14.38.33135... 100% + +vcredist140 v14.38.33135 [Approved] +vcredist140 package files install completed. Performing other installation steps. +Downloading vcredist140-x86 + from 'https://download.visualstudio.microsoft.com/download/pr/71c6392f-8df5-4b61-8d50-dba6a525fb9d/510FC8C2112E2BC544FB29A72191EABCC68D3A5A7468D35D7694493BC8593A79/VC_redist.x86.exe' +Progress: 100% - Completed download of C:\Users\ContainerAdministrator\AppData\Local\Temp\chocolatey\vcredist140\14.38.33135\VC_redist.x86.exe (13.21 MB). +Download of VC_redist.x86.exe (13.21 MB) completed. +Hashes match. +Installing vcredist140-x86... + +vcredist140-x86 has been installed. +Downloading vcredist140-x64 64 bit + from 'https://download.visualstudio.microsoft.com/download/pr/6ba404bb-6312-403e-83be-04b062914c98/1AD7988C17663CC742B01BEF1A6DF2ED1741173009579AD50A94434E54F56073/VC_redist.x64.exe' +Progress: 100% - Completed download of C:\Users\ContainerAdministrator\AppData\Local\Temp\chocolatey\vcredist140\14.38.33135\VC_redist.x64.exe (24.24 MB). +Download of VC_redist.x64.exe (24.24 MB) completed. +Hashes match. +Installing vcredist140-x64... + +vcredist140-x64 has been installed. + vcredist140 may be able to be automatically uninstalled. + The install of vcredist140 was successful. + Software installed as 'exe', install location is likely default. +Progress: Downloading OpenSSL.Light 3.1.4... 100% + +OpenSSL.Light v3.1.4 [Approved] +OpenSSL.Light package files install completed. Performing other installation steps. +Installing 64-bit OpenSSL.Light... +OpenSSL.Light has been installed. +Installed to 'C:\Program Files\OpenSSL' +PATH environment variable does not have C:\Program Files\OpenSSL\bin in it. Adding... + OpenSSL.Light can be automatically uninstalled. +Environment Vars (like PATH) have changed. Close/reopen your shell to + see the changes (or in powershell/cmd.exe just type `refreshenv`). + The install of OpenSSL.Light was successful. + Software installed to 'C:\Program Files\OpenSSL\' + +Chocolatey installed 10/10 packages. + See the log for details (C:\ProgramData\chocolatey\logs\chocolatey.log). + +Installed: + - chocolatey-compatibility.extension v1.0.0 + - chocolatey-core.extension v1.4.0 + - chocolatey-windowsupdate.extension v1.0.5 + - KB2919355 v1.0.20160915 + - KB2919442 v1.0.20160915 + - KB2999226 v1.0.20181019 + - KB3033929 v1.0.5 + - KB3035131 v1.0.3 + - OpenSSL.Light v3.1.4 + - vcredist140 v14.38.33135 +Removing intermediate container c6ba27fdfc95 + ---> 84129bb8fe16 +Step 17/20 : EXPOSE $InstanceInternalPort + ---> Running in cbd1cddb7c1d +Removing intermediate container cbd1cddb7c1d + ---> 1288edc4cedf +Step 18/20 : EXPOSE $CallSignalingPort + ---> Running in 0f39208ecd1d +Removing intermediate container 0f39208ecd1d + ---> d3e9672a7c58 +Step 19/20 : EXPOSE $CallSignalingPort2 + ---> Running in db0051825c9c +Removing intermediate container db0051825c9c + ---> 1ffd1f3a836e +Step 20/20 : ENTRYPOINT [ "entrypoint.cmd" ] + ---> Running in 3534e4f45cc9 +Removing intermediate container 3534e4f45cc9 + ---> 08e17dfd3ff1 +Successfully built 08e17dfd3ff1 +Successfully tagged recordingbotregistry.azurecr.io/recordingbottutorial/application:latest +2024/04/22 13:26:57 Successfully executed container: build +2024/04/22 13:26:57 Executing step ID: push. Timeout(sec): 3600, Working directory: '', Network: '' +2024/04/22 13:26:57 Pushing image: recordingbotregistry.azurecr.io/recordingbottutorial/application:latest, attempt 1 +The push refers to repository [recordingbotregistry.azurecr.io/recordingbottutorial/application] +b9617601bebb: Preparing +bcb99b7d948d: Preparing +8a7e2c66e2ef: Preparing +fca722bde849: Preparing +4f95d08eea8e: Preparing +e7f48d9387aa: Preparing +b4a29475681c: Preparing +f873e4575f3d: Preparing +a5ffa8236791: Preparing +937f4a68c6f2: Preparing +804723c997b5: Preparing +9ea5853ffacc: Preparing +f1f5d7dbc442: Preparing +058f8a7cd302: Preparing +e7f48d9387aa: Waiting +b4a29475681c: Waiting +f873e4575f3d: Waiting +a5ffa8236791: Waiting +937f4a68c6f2: Waiting +804723c997b5: Waiting +9ea5853ffacc: Waiting +f1f5d7dbc442: Waiting +058f8a7cd302: Waiting +bcb99b7d948d: Pushed +fca722bde849: Pushed +b4a29475681c: Pushed +b9617601bebb: Pushed +8a7e2c66e2ef: Pushed +a5ffa8236791: Pushed +937f4a68c6f2: Pushed +e7f48d9387aa: Pushed +9ea5853ffacc: Pushed +804723c997b5: Pushed +4f95d08eea8e: Pushed +f873e4575f3d: Pushed + +f1f5d7dbc442: Pushed +058f8a7cd302: Pushed +latest: digest: sha256:425bde01b22d5b1829f9f79117e51ee9ca3ca822f7477a09a788f390a04379d0 size: 3258 +2024/04/22 13:34:01 Successfully pushed image: recordingbotregistry.azurecr.io/recordingbottutorial/application:latest +2024/04/22 13:34:01 Step ID: build marked as successful (elapsed time in seconds: 986.312550) +2024/04/22 13:34:01 Populating digests for step ID: build... + +2024/04/22 13:34:12 Successfully populated digests for step ID: build +2024/04/22 13:34:12 Step ID: push marked as successful (elapsed time in seconds: 423.445314) +2024/04/22 13:34:12 The following dependencies were found: +2024/04/22 13:34:12 +- image: + registry: recordingbotregistry.azurecr.io + repository: recordingbottutorial/application + tag: latest + digest: sha256:425bde01b22d5b1829f9f79117e51ee9ca3ca822f7477a09a788f390a04379d0 + runtime-dependency: + registry: mcr.microsoft.com + repository: windows/server + tag: ltsc2022 + digest: sha256:f2a7ad9732bdaf680bcadb270101f1908cf9969581b094c3279f1481eb181a71 + buildtime-dependency: + - registry: mcr.microsoft.com + repository: dotnet/sdk + tag: 8.0-windowsservercore-ltsc2022 + digest: sha256:ce3009d6cb2c647ae0e1bb8cc984d643611ced83704b1c1f331178853e5d7e7d + git: {} + + +Run ID: cb1 was successful after 24m5s +``` + +We now have a docker container in our registry and can continue with [creating and configuring a Bot Service](./4-bot-service.md). diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/4-bot-service.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/4-bot-service.md new file mode 100644 index 000000000..746b76c26 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/4-bot-service.md @@ -0,0 +1,295 @@ +# Create and configure Bot Service + +Let us now create an App Registration with the application permission to access the calls and the +media streams of the calls. After that we can create a Bot Service, link our App Registration, +and configure the notification URL. + +## Create Azure App Registration + +To create the App Registration we run: + +> [!NOTE] +> The App Registration we create here is a multi tenant app registration, also see [this](https://learn.microsoft.com/de-de/cli/azure/ad/app?view=azure-cli-latest#az-ad-app-create-optional-parameters) for reference. + +```powershell +az ad app create + --display-name recordingbotregistration + --sign-in-audience AzureADMultipleOrgs + --key-type Password +``` + +The output should look similar to: + +```json +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications/$entity", + "addIns": [], + "api": { + "acceptMappedClaims": null, + "knownClientApplications": [], + "oauth2PermissionScopes": [], + "preAuthorizedApplications": [], + "requestedAccessTokenVersion": null + }, + "appId": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "appRoles": [], + "applicationTemplateId": null, + "certification": null, + "createdDateTime": "2024-04-23T09:52:05.4291387Z", + "defaultRedirectUri": null, + "deletedDateTime": null, + "description": null, + "disabledByMicrosoftStatus": null, + "displayName": "recordingbotregistration", + "groupMembershipClaims": null, + "id": "bbe622a5-6cc4-41f1-a682-1368751b8029", + "identifierUris": [], + "info": { + "logoUrl": null, + "marketingUrl": null, + "privacyStatementUrl": null, + "supportUrl": null, + "termsOfServiceUrl": null + }, + "isDeviceOnlyAuthSupported": null, + "isFallbackPublicClient": null, + "keyCredentials": [], + "notes": null, + "optionalClaims": null, + "parentalControlSettings": { + "countriesBlockedForMinors": [], + "legalAgeGroupRule": "Allow" + }, + "passwordCredentials": [], + "publicClient": { + "redirectUris": [] + }, + "publisherDomain": "lm-ag.de", + "requestSignatureVerification": null, + "requiredResourceAccess": [], + "samlMetadataUrl": null, + "serviceManagementReference": null, + "servicePrincipalLockConfiguration": null, + "signInAudience": "AzureADMultipleOrgs", + "spa": { + "redirectUris": [] + }, + "tags": [], + "tokenEncryptionKeyId": null, + "uniqueName": null, + "verifiedPublisher": { + "addedDateTime": null, + "displayName": null, + "verifiedPublisherId": null + }, + "web": { + "homePageUrl": null, + "implicitGrantSettings": { + "enableAccessTokenIssuance": false, + "enableIdTokenIssuance": false + }, + "logoutUrl": null, + "redirectUriSettings": [], + "redirectUris": [] + } +} +``` + +Now it is very important that you write down the value of your _appId_-field as this is the +App Registration Id, in the example output this value is: `cccccccc-cccc-cccc-cccc-cccccccccccc` + +### Add Graph API application permission + +Next we need to add the application permission that are required for the recording bot application +to our App Registration. The Permissions and the API of the App Registration are referenced by IDs +as we use Micrsoft Graph API the API Id is `00000003-0000-0000-c000-000000000000`(as this is the +App Registration Id of the Microsoft Graph API), for the permission ids we use the +[docs by microsoft](https://learn.microsoft.com/en-us/graph/permissions-reference) for reference. + +The three permissions we add for our recording bot are: + +- _Calls.AccessMedia.All_ : a7a681dc-756e-4909-b988-f160edc6655f +- _Calls.JoinGroupCall.All_ : f6b49018-60ab-4f81-83bd-22caeabfed2d +- _Calls.JoinGroupCallAsGuest.All_ : fd7ccf6b-3d28-418b-9701-cd10f5cd2fd4 + +```powershell +az ad app permission add + --id cccccccc-cccc-cccc-cccc-cccccccccccc + --api 00000003-0000-0000-c000-000000000000 + --api-permissions a7a681dc-756e-4909-b988-f160edc6655f=Role f6b49018-60ab-4f81-83bd-22caeabfed2d=Role fd7ccf6b-3d28-418b-9701-cd10f5cd2fd4=Role +``` + +The output the command should look similar to: + +```text +Invoking `az ad app permission grant --id cccccccc-cccc-cccc-cccc-cccccccccccc --api 00000003-0000-0000-c000-000000000000` is needed to make the change effective +``` + +### Grant application permssion to tenant + +For the application permissions to take effect, we have to grant the application permissions to our tenant: + +```powershell +az ad app permission admin-consent + --id cccccccc-cccc-cccc-cccc-cccccccccccc +``` + +If the command run successfully, we shouldn't see any output text in our console. + +### Create App Secret + +Next we create an App Secret for our App Registration. The bot application will uses this secret to +authenticate. The secret we generate will be valid for 1 year, after that we have to create a new secret. + +```powershell +az ad app credential reset + --id cccccccc-cccc-cccc-cccc-cccccccccccc + --years 1 + --query "password" +``` + +The output will look similar to: + +```text +The output includes credentials that you must protect. Be sure that you do not include these credentials in your code or check the credentials into your source control. For more information, see https://aka.ms/azadsp-cli +"abcdefghijklmnopqrstuvwxyz" +``` + +The text in the quotation marks is the App Secret, we will store the secret later in a special +store in the AKS cluster. Handle this App Secret carefully, like it is your own password. + +## Create Azure Bot Service + +Since we have created and configured the App Registration we can continue with creating the Bot Service. + +> [!NOTE] +> As we created a multi tenant App Registration earlier we will also create the Bot Service as +> multi tenant, also see [this](https://learn.microsoft.com/en-us/cli/azure/bot?view=azure-cli-latest#az-bot-create-required-parameters) for reference. + +```powershell +az bot create + --name recordingbotservice + --resource-group recordingbottutorial + --appid cccccccc-cccc-cccc-cccc-cccccccccccc + --app-type MultiTenant + --location global + --subscription "recordingbotsubscription" +``` + +The result of the command should look similar to: + +```json +Resource provider 'Microsoft.BotService' used by this operation is not registered. We are registering for you. +Registration succeeded. +{ + "etag": "\"d71e2695-aaf4-4fe5-b1a5-81610d867c35\"", + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/recordingbottutorial/providers/Microsoft.BotService/botServices/recordingbotservice", + "kind": "azurebot", + "location": "global", + "name": "recordingbotservice", + "properties": { + "allSettings": null, + "appPasswordHint": null, + "cmekEncryptionStatus": "Off", + "cmekKeyVaultUrl": null, + "configuredChannels": [ + "webchat", + "directline" + ], + "description": null, + "developerAppInsightKey": null, + "developerAppInsightsApiKey": null, + "developerAppInsightsApplicationId": null, + "disableLocalAuth": false, + "displayName": "recordingbotservice", + "enabledChannels": [ + "webchat", + "directline" + ], + "endpoint": "", + "endpointVersion": "3.0", + "iconUrl": "https://docs.botframework.com/static/devportal/client/images/bot-framework-default.png", + "isCmekEnabled": false, + "isDeveloperAppInsightsApiKeySet": false, + "isStreamingSupported": false, + "luisAppIds": [], + "luisKey": null, + "manifestUrl": null, + "migrationToken": null, + "msaAppId": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "msaAppMsiResourceId": null, + "msaAppTenantId": null, + "msaAppType": "MultiTenant", + "openWithHint": null, + "parameters": null, + "privateEndpointConnections": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": "Enabled", + "publishingCredentials": null, + "schemaTransformationVersion": "1.3", + "storageResourceId": null, + "tenantId": "99999999-9999-9999-9999-999999999999" + }, + "resourceGroup": "recordingbottutorial", + "sku": { + "name": "F0", + "tier": null + }, + "tags": {}, + "type": "Microsoft.BotService/botServices", + "zones": [] +} +``` + +### Add notification URL to Bot Service + +Next let us configure the notification URL of the recording bot. Even though we have not deployed +our recording bot yet, we already know the DNS name and the path and port we want to have the notifications on. + +> [!NOTE] +> As you might have noticed aready, we now need the +> fully quialified domain name that we created earlier for our AKS cluster. + +```powershell +az bot msteams create + --name recordingbotservice + --resource-group recordingbottutorial + --enable-calling + --calling-web-hook https://recordingbottutorial.westeurope.cloudapp.azure.com/api/calling + --subscription "recordingbotsubscription" +``` + +The result should look similar to: + +```json +Command group 'bot msteams' is in preview and under development. Reference and support levels: https://aka.ms/CLI_refstatus +{ + "etag": "W/\"911970bd-5993-471a-9f4a-cb55321f1710/23/2024 11:17:15 AM\"", + "id": "/subscriptions/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyy/resourceGroups/recordingbottutorial/providers/Microsoft.BotService/botServices/recordingbotservice/channels/MsTeamsChannel", + "kind": null, + "location": "global", + "name": "recordingbotservice/MsTeamsChannel", + "properties": { + "channelName": "MsTeamsChannel", + "etag": "W/\"911970bd-5993-471a-9f4a-cb55321f1710/23/2024 11:17:15 AM\"", + "location": "global", + "properties": { + "acceptedTerms": null, + "callingWebHook": null, + "callingWebhook": "https://recordingbottutorial.westeurope.cloudapp.azure.com/api/calling", + "deploymentEnvironment": "CommercialDeployment", + "enableCalling": true, + "incomingCallRoute": null, + "isEnabled": true + }, + "provisioningState": "Succeeded" + }, + "resourceGroup": "recordingbottutorial", + "sku": null, + "tags": null, + "type": "Microsoft.BotService/botServices/channels", + "zones": [] +} +``` + +In the next step we will [deploy the sample recording bot application to our AKS cluster](5-helm.md). diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/5-helm.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/5-helm.md new file mode 100644 index 000000000..1e77569bb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/5-helm.md @@ -0,0 +1,191 @@ +# Deploy recording bot sample to AKS cluster + +To start our deployment we first make sure that we are still in the correct folder, if we did not +change the directory since building the Docker container we can continue with [deploying cert manager](#deploy-cert-manager) + +```powershell +cd C:\Users\User\recordingbottutorial +``` + +And change the directory to the sample project in the repository. + +```powershell +cd .\aks-sample\Samples\PublicSamples\RecordingBot\ +``` + +## Deploy Cert Manager + +Like any local media bots, the sample needs a properly signed certificate, with a trust chain up to +generally trusted root authority. To create the certificate for the sample in our AKS cluster +we use cert manager with [Let's Encrypt](https://letsencrypt.org/). + +To deploy cert manager we run: + +```powershell +cmd.exe /c '.\deploy\cert-manager\install.bat' +``` + +The result should look similar to: + +```text +Updating helm repo +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "jetstack" chart repository +Update Complete. ⎈Happy Helming!⎈ +Installing cert-manager +Release "cert-manager" does not exist. Installing it now. +NAME: cert-manager +LAST DEPLOYED: Wed Apr 24 09:56:58 2024 +NAMESPACE: cert-manager +STATUS: deployed +REVISION: 1 +TEST SUITE: None +NOTES: +cert-manager v1.13.3 has been deployed successfully! + +In order to begin issuing certificates, you will need to set up a ClusterIssuer +or Issuer resource (for example, by creating a 'letsencrypt-staging' issuer). + +More information on the different types of issuers and how to configure them +can be found in our documentation: + +https://cert-manager.io/docs/configuration/ + +For information on how to configure cert-manager to automatically provision +Certificates for Ingress resources, take a look at the `ingress-shim` +documentation: + +https://cert-manager.io/docs/usage/ingress/ +Waiting for cert-manager to be ready +pod/cert-manager-57688f5dc6-znq89 condition met +pod/cert-manager-cainjector-d7f8b5464-tswft condition met +pod/cert-manager-webhook-58fd67545d-7fwp7 condition met +Press any key . . . +``` + +And we press a key on our keyboard to exit from the command. + +## Create namespace for recording bot + +Next let us create a namespace in our AKS cluster to which we will deploy the sample. + +```powershell +kubectl create namespace recordingbottutorial +``` + +The output should look similar to: + +```text +namespace/recordingbottutorial created +``` + +## Store App Registration Id and Secret in AKS cluster + +The App Registration Id and Secret are stored in an object designed to hold secrets in the AKS cluster. + +```powershell +kubectl create secret generic bot-application-secrets + --namespace recordingbottutorial + --from-literal=applicationId="cccccccc-cccc-cccc-cccc-cccccccccccc" + --from-literal=applicationSecret="abcdefghijklmnopqrstuvwxyz" + --from-literal=botName="Tutorial Bot" +``` + +> [!NOTE] +> The name `bot-application-secrets` could be changed, but the new name must then also be provided +> to the chart of the sample, how to do this, is out of scope of this tutorial. + +The output should look similar to: + +```text +secret/bot-application-secrets created +``` + +## Deploy recording bot sample + +Now we can deploy the recording bot sample. + +First we have to load the dependencies of the [Helm Chart](https://helm.sh/docs/topics/charts/) that deploys the sample: + +```powershell +helm dependency update .\deploy\teams-recording-bot\ +``` + +The output of that should look similar to: + +```text +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "ingress-nginx" chart repository +...Successfully got an update from the "jetstack" chart repository +...Successfully got an update from the "stable" chart repository +Update Complete. ⎈Happy Helming!⎈ +Saving 1 charts +Downloading ingress-nginx from repo https://kubernetes.github.io/ingress-nginx +Deleting outdated charts +``` + +Next we build the dependencies: + +```powershell +helm dependency build .\deploy\teams-recording-bot\ +``` + +The output should look similar to: + +```text +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "ingress-nginx" chart repository +...Successfully got an update from the "jetstack" chart repository +...Successfully got an update from the "stable" chart repository +Update Complete. ⎈Happy Helming!⎈ +Saving 1 charts +Downloading ingress-nginx from repo https://kubernetes.github.io/ingress-nginx +Deleting outdated charts +``` + +Now we can deploy the chart of the recording bot sample to our aks cluster. Do not forget to change +the example values with the values you have written down during the previous steps. + +```powershell +helm upgrade recordingbottutorial .\deploy\teams-recording-bot\ + --install + --namespace recordingbottutorial + --set image.registry="recordingbotregistry.azurecr.io/recordingbottutorial" + --set image.name="application" + --set image.tag="latest" + --set public.ip="255.255.255.255" + --set host="recordingbottutorial.westeurope.cloudapp.azure.com" + --set ingress.tls.email="tls-security@lm-ag.de" +``` + +The output should look similar to: + +```text +Release "recordingbottutorial" does not exist. Installing it now. +NAME: recordingbottutorial +LAST DEPLOYED: Wed Apr 24 10:56:03 2024 +NAMESPACE: recordingbottutorial +STATUS: deployed +REVISION: 1 +TEST SUITE: None +``` + +Now we need to open our browser at `https://recordingbottutorial.westeurope.cloudapp.azure.com/calls` +(your fully qualified domain name + _/calls_) when first loading we should see a certificate error. +As only now the cert manager starts to create the certificate. After waiting some time ~2mins we can +reload the page and should see a service unavailable screen. After waiting some more time ~15mins +for the containers to be started on the windows nodes, we can see an empty JSON result (see image below). + +![Working result page](../../images/screenshot-no-calls-web-page.png) + +> [!NOTE] +> If the certificate do not get ready it is possible that Let's Encrypt reached an API limit +> for `westeurope.cloudapp.azure.com`, if that is the case you can either wait and retry at the +> start of next week. You can shutdown the cluster in the meantime but you do not have to. Or you can +> create a CNAME record on a custom domain that points to your `westeurope.cloudapp.azure.com` +> -domain. If you create a custom domain, do not forget to update the bot service channel with the +> custom domain and redo the `helm upgrade` command with your custom domain as host. To check if +> the rate limit is reached, run `kubectl describe certificate ingress-tls-recordingbottutorial --namespace recordingbottutorial` +> and check the output for a rate limit error + +In the next step we will [set up a recording policy for all users in our Microsoft Entra ID tenant](./6-policy.md). diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/6-policy.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/6-policy.md new file mode 100644 index 000000000..463c96dfb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/6-policy.md @@ -0,0 +1,146 @@ +# Create and assign recording policy + +In this step we will create a recording policy and assign the recording policy to all users of our +Microsoft Entra ID tenant. + +## Create recording policy + +Let us start with creating a recording policy. + +### Install Powershell module + +Before we can start to create the policy, we have to install the Microsoft Teams powershell module. + +> [!NOTE] +> If you already have the powershell module installed please run `Update-Module MicrosoftTeams`, +> load the modulue and continue with [log in to the powershell module](#log-in-to-teams-powershell-module) + +To do install the module we first have to set a new powershell execution policy in an evelated +(Run as Admin) powershell terminal: + +```powershell +Set-PsExecutionPolicy RemoteSigned +``` + +The command should not output anything to the console and we can continue with installing the +Microsoft Teams powershell moduel: + +```powershell +Install-Module MicrosoftTeams +``` + +The output of the command asks us if we want to install the powershell module from an untrusted repository: + +```text +Untrusted repository +You are installing the modules from an untrusted repository. If you trust this repository, change its +InstallationPolicy value by running the Set-PSRepository cmdlet. Are you sure you want to install the modules from +'PSGallery'? +[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "N"): +``` + +Which we will accept with `y`. After that we should see a loading bar and the installation should +finish successful with no further output. Then we can load the module: + +```powershell +Import-Module MicrosoftTeams +``` + +Which again should not print anything to our terminal. + +### Log in to Teams powershell module + +To do the Teams powershell module we run: + +```powershell +Connect-MicrosoftTeams +``` + +The command should open our default browser and show a page similar to: + +![Login Page](../../images/screenshot-login.png) + +On the Microsoft Login Page we log in with our Microsoft Entra ID administrator account and accept +requested scopes. + +The output in the terminal after we successfully logged in and accepted scopes should look similar to: + +```text + +Account Environment Tenant TenantId +------- ----------- ------ -------- +user@xyz.com AzureCloud 99999999-9999-9999-9999-999999999999 99999999-9999-9999-9999-999999999999 + +``` + +### Create an Application Instance + +Now we can create an Application Instance that is linked to our App Registration: + +```powershell +New-CsOnlineApplicationInstance + -UserPrincipalName tutorialbot@lm-ag.de + -DisplayName "Tutorial Bot" + -ApplicationId cccccccc-cccc-cccc-cccc-cccccccccccc +``` + +The output of that should be similar to: + +```text +ApplicationId DisplayName ObjectId PhoneNumber TenantId UserPrincipalName +------------- ----------- -------- ----------- -------- ----------------- +cccccccc-cccc-cccc-cccc-cccccccccccc Tutorial Bot 11111111-1111-1111-1111-111111111111 tutorialbot@lm-ag.de +``` + +We copy the _ObjectId_ from the output(in the example output it is +`11111111-1111-1111-1111-111111111111`) and save it for later. + +After that we sync the Application Instance into the remote service: + +```powershell +Sync-CsOnlineApplicationInstance + -ObjectId 11111111-1111-1111-1111-111111111111 + -ApplicationId cccccccc-cccc-cccc-cccc-cccccccccccc +``` + +When the command ran successful we should not see any additional output in our terminal. + +### Create a Recording Policy + +After creating the Application Instance we can continue with creating a Recording Policy: + +```powershell +New-CsTeamsComplianceRecordingPolicy + -Identity "TutorialPolicy" + -Enabled $true +``` + +The creation of a policy should not print anything to our terminal. + +### Create a Recording Application + +In the next step we need to link the Application Instance and the Recording Policy, to do so we +create a Recording Application: + +```powershell +New-CsTeamsComplianceRecordingApplication + -Parent "TutorialPolicy" + -Id 11111111-1111-1111-1111-111111111111 +``` + +If the command executed successfully, we should see no output in our terminal window. + +## Assign recording policy to users in tenant + +Now that we have created a policy, we can assign the policy to all users in our tenant: + +```powershell +Grant-CsTeamsComplianceRecordingPolicy + -Global + -PolicyName "TutorialPolicy" +``` + +Similar to the previous commands this one also does not print anything to our terminal. + +In the next and final step we will check if the sample works and +[validate if we can see a recording notification in teams](./7-test.md). diff --git a/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/7-test.md b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/7-test.md new file mode 100644 index 000000000..295aee53c --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/docs/tutorials/deploy/7-test.md @@ -0,0 +1,19 @@ +# Verify functionality + +In order for us to verify the functionality, we need two Teams users in our tenant. Either ask a +colleague to log in to their Teams client or use a browser profile with [Microsoft Teams](https://teams.cloud.microsoft) +for each Teams user. + +## Setup Test Call + +After both user accounts are freshly logged in to their Teams client, we can directly call one Teams +user with the other client. + +## Verify Recording Banner + +After we successfully set up a test call we should see the a banner in both of our Teams clients: + +![Recording Banner](../../images/screenshot-recording-banner.png) + +Congratulations, we deployed and successfully configured a Compliance Recording Bot, in the next +steps we can customize the Recording Bot Application to meet our custom use-cases. diff --git a/Samples/PublicSamples/RecordingBot/scripts/certs.bat-template b/Samples/PublicSamples/RecordingBot/scripts/certs.bat-template new file mode 100644 index 000000000..d7d8128cc --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/certs.bat-template @@ -0,0 +1,22 @@ +REM Set up Environment Variables +./set_env.cmd .env + +set /A CallSignalingPort2 = %AzureSettings__CallSignalingPort% + 1 + +REM Deleting bindings +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% +netsh http delete urlacl url=https://+:%AzureSettings__CallSignalingPort%/ +netsh http delete urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ +netsh http delete urlacl url=http://+:%CallSignalingPort2%/ + +REM Add URLACL bindings +netsh http add urlacl url=https://+:%AzureSettings__CallSignalingPort%/ sddl=D:(A;;GX;;;S-1-1-0) +netsh http add urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ sddl=D:(A;;GX;;;S-1-1-0) +netsh http add urlacl url=http://+:%CallSignalingPort2%/ sddl=D:(A;;GX;;;S-1-1-0) + +REM ensure the app id matches the GUID in AssemblyInfo.cs +REM Ensure the certhash matches the certificate + +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% certhash=YOUR_CERT_THUMBPRINT appid={aeeb866d-e17b-406f-9385-32273d2f8691} +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% certhash=YOUR_CERT_THUMBPRINT appid={aeeb866d-e17b-406f-9385-32273d2f8691} diff --git a/Samples/PublicSamples/RecordingBot/scripts/config.ini b/Samples/PublicSamples/RecordingBot/scripts/config.ini new file mode 100644 index 000000000..a87f25073 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/config.ini @@ -0,0 +1,2 @@ +email=zhangzihan1993@outlook.com +domain=teams-recording-bot.ngrok.io diff --git a/Samples/PublicSamples/RecordingBot/scripts/config.ini.template b/Samples/PublicSamples/RecordingBot/scripts/config.ini.template new file mode 100644 index 000000000..03c07a7cd --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/config.ini.template @@ -0,0 +1,2 @@ +email= +domain=yourdomain.ngrok.io diff --git a/Samples/PublicSamples/RecordingBot/scripts/config.sh b/Samples/PublicSamples/RecordingBot/scripts/config.sh new file mode 100644 index 000000000..b31ade014 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/config.sh @@ -0,0 +1,3 @@ +SUBDOMAIN=teams-recording-bot +AUTHTOKEN=1k3utdNZvYD4kpBJLUT2LuD5tOf_5iP4GGtojRs9Qfwx892VM +CERTIFICATEPASSWORD= \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/scripts/config.sh.template b/Samples/PublicSamples/RecordingBot/scripts/config.sh.template new file mode 100644 index 000000000..f9810a441 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/config.sh.template @@ -0,0 +1,3 @@ +SUBDOMAIN= #example: yourdomain +AUTHTOKEN= #get from ngrok dashboard (from the sample) +CERTIFICATEPASSWORD= #password used when creating certificate.pfx diff --git a/Samples/PublicSamples/RecordingBot/scripts/entrypoint.cmd b/Samples/PublicSamples/RecordingBot/scripts/entrypoint.cmd new file mode 100644 index 000000000..cb538f0a3 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/entrypoint.cmd @@ -0,0 +1,49 @@ +@echo off + +IF "%1"=="-v" ( + .\RecordingBot.Console.exe -v + exit /b 0 +) + +:: --- Ensure the VC_redist is installed for the Microsoft.Skype.Bots.Media Library --- +echo Setup: Starting VC_redist +.\VC_redist.x64.exe /quiet /norestart + +echo Setup: Converting certificate +powershell.exe C:\Program` Files\OpenSSL\bin\openssl.exe pkcs12 -export -out C:\bot\certificate.pfx -passout pass: -inkey C:\certs\tls.key -in C:\certs\tls.crt + +echo Setup: Installing certificate +certutil -f -p "" -importpfx certificate.pfx +powershell.exe "(Get-PfxCertificate -FilePath certificate.pfx).Thumbprint" > thumbprint +set /p AzureSettings__CertificateThumbprint= < thumbprint +del thumbprint +del certificate.pfx + +set /A CallSignalingPort2 = %AzureSettings__CallSignalingPort% + 1 + +:: --- Delete existing certificate bindings and URL ACL registrations --- +echo Setup: Deleting bindings +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% > nul +netsh http delete sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% > nul +netsh http delete urlacl url=https://+:%AzureSettings__CallSignalingPort%/ > nul +netsh http delete urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ > nul +netsh http delete urlacl url=http://+:%CallSignalingPort2%/ > nul + +:: --- Add new URL ACLs and certificate bindings --- +echo Setup: Adding bindings +netsh http add urlacl url=https://+:%AzureSettings__CallSignalingPort%/ sddl=D:(A;;GX;;;S-1-1-0) > nul && ^ +netsh http add urlacl url=https://+:%AzureSettings__InstanceInternalPort%/ sddl=D:(A;;GX;;;S-1-1-0) > nul && ^ +netsh http add urlacl url=http://+:%CallSignalingPort2%/ sddl=D:(A;;GX;;;S-1-1-0) > nul && ^ +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__CallSignalingPort% certhash=%AzureSettings__CertificateThumbprint% appid={aeeb866d-e17b-406f-9385-32273d2f8691} > nul && ^ +netsh http add sslcert ipport=0.0.0.0:%AzureSettings__InstanceInternalPort% certhash=%AzureSettings__CertificateThumbprint% appid={aeeb866d-e17b-406f-9385-32273d2f8691} > nul + +if errorlevel 1 ( + echo Setup: Failed to add URL ACLs and certificate bings. + exit /b %errorlevel% +) + +echo Setup: Done +echo --------------------- + +:: --- Running bot --- +.\RecordingBot.Console.exe diff --git a/Samples/PublicSamples/RecordingBot/scripts/halt_termination.ps1 b/Samples/PublicSamples/RecordingBot/scripts/halt_termination.ps1 new file mode 100644 index 000000000..9c45ec84f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/halt_termination.ps1 @@ -0,0 +1,24 @@ +$continue = $true + +$CallSignalingPort2 = [int]$env:AzureSettings__CallSignalingPort + 1 + +while($continue) +{ + try + { + $result = Invoke-WebRequest -Uri "http://localhost:$CallSignalingPort2/calls" -UseBasicParsing + + if ($result.Content) + { + Start-Sleep -Seconds 60 + } + else + { + $continue = $false + } + } + catch + { + "Error while calling endpoint." + } +} diff --git a/Samples/PublicSamples/RecordingBot/scripts/host.sh b/Samples/PublicSamples/RecordingBot/scripts/host.sh new file mode 100644 index 000000000..21c663749 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/host.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +. ./config.sh + +ngrok authtoken $AUTHTOKEN + +ROOT=$PWD/etc +if [ ! -d "$ROOT" ]; then + mkdir $ROOT +fi + +CERTNAME="$SUBDOMAIN.pfx" + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + FULLPATHCERTS=/etc/letsencrypt + + DIR=/workspace/letsencrypt/renewal + + if test -d "$DIR"; then + echo "Certs exist, copying" + if [ ! -d $FULLPATHCERTS ]; then + mkdir $FULLPATHCERTS + fi + cp -r ./letsencrypt $ROOT + else + ngrok http -host-header="$SUBDOMAIN.ngrok.io" -subdomain="$SUBDOMAIN" 80 > /dev/null & + #wait for ngrok + sleep 5s + certbot certonly --config config.ini --standalone --preferred-challenges http + cp -r $FULLPATHCERTS ./ + openssl pkcs12 -export \ + -out $ROOT/$CERTNAME \ + -inkey ./letsencrypt/archive/$SUBDOMAIN.ngrok.io/privkey1.pem \ + -in ./letsencrypt/archive/$SUBDOMAIN.ngrok.io/cert1.pem \ + -certfile ./letsencrypt/archive/$SUBDOMAIN.ngrok.io/chain1.pem \ + -passout pass:$CERTIFICATEPASSWORD + fi + +elif [[ "$OSTYPE" == "msys"* ]]; then + + CERTBOTDIR=C:/Certbot/live/$SUBDOMAIN.ngrok.io + + if test -f "$ROOT/$CERTNAME"; then + echo "Certs exist, exiting" + else + ngrok http -host-header="$SUBDOMAIN.ngrok.io" -subdomain="$SUBDOMAIN" 80 > /dev/null & + #wait for ngrok + sleep 5s + certbot certonly --config config.ini --standalone --preferred-challenges http + openssl pkcs12 -export \ + -out $ROOT/$CERTNAME \ + -inkey $CERTBOTDIR/privkey1.pem \ + -in $CERTBOTDIR/cert1.pem \ + -certfile $CERTBOTDIR/chain1.pem \ + -passout pass:$CERTIFICATEPASSWORD + echo "A new certificate has been created and found here: $ROOT/$CERTNAME" + fi +else + echo "Current OS: $OSTYPE. This operating system is not supported." +fi + + diff --git a/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml-template b/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml-template new file mode 100644 index 000000000..2155a1719 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml-template @@ -0,0 +1,11 @@ +authtoken: AUTH_TOKEN +tunnels: + signaling: + addr: "https://localhost:" + proto: http + subdomain: YOUR_SUBDOMAIN + host_header: "localhost:" + media: + addr: + proto: tcp + remote_addr: YOUR_FULL_RESERVED_TCP_PORT diff --git a/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml.template b/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml.template new file mode 100644 index 000000000..0bc4a37a9 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/ngrok.yaml.template @@ -0,0 +1,11 @@ +tunnels: + http: + addr: "http://host.docker.internal:80" + proto: http + subdomain: SUBDOMAIN + host_header: "host.docker.internal:80" + https: + addr: "https://host.docker.internal:443" + proto: http + subdomain: SUBDOMAIN + host_header: "host.docker.internal:443" diff --git a/Samples/PublicSamples/RecordingBot/scripts/runngrok.bat b/Samples/PublicSamples/RecordingBot/scripts/runngrok.bat new file mode 100644 index 000000000..0030f0627 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/runngrok.bat @@ -0,0 +1,2 @@ +REM +ngrok start -all -config ngrok.yaml diff --git a/Samples/PublicSamples/RecordingBot/scripts/set_env.cmd b/Samples/PublicSamples/RecordingBot/scripts/set_env.cmd new file mode 100644 index 000000000..c8211ae61 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/scripts/set_env.cmd @@ -0,0 +1,23 @@ +@echo off +setlocal ENABLEDELAYEDEXPANSION +set vidx=0 +for /F "tokens=*" %%A in (%1%) do ( + set /A ind=!vidx! + set /A vidx=!vidx! + 1 + set var!vidx!=%%A +) +set var + + +FOR /F "delims== tokens=1" %%k IN ("%var7%") DO FOR /f "delims== tokens=2" %%v IN ("%var1%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var2%") DO FOR /f "delims== tokens=2" %%v IN ("%var2%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var3%") DO FOR /f "delims== tokens=2" %%v IN ("%var3%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var4%") DO FOR /f "delims== tokens=2" %%v IN ("%var4%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var5%") DO FOR /f "delims== tokens=2" %%v IN ("%var5%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var6%") DO FOR /f "delims== tokens=2" %%v IN ("%var6%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var7%") DO FOR /f "delims== tokens=2" %%v IN ("%var7%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var8%") DO FOR /f "delims== tokens=2" %%v IN ("%var8%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var9%") DO FOR /f "delims== tokens=2" %%v IN ("%var9%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var10%") DO FOR /f "delims== tokens=2" %%v IN ("%var10%") DO SET %%k=%%v +FOR /F "delims== tokens=1" %%k IN ("%var11%") DO FOR /f "delims== tokens=2" %%v IN ("%var11%") DO SET %%k=%%v + diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/.env-template b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/.env-template new file mode 100644 index 000000000..2af0888ca --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/.env-template @@ -0,0 +1,16 @@ +AzureSettings__BotName= +AzureSettings__AadAppId= +AzureSettings__AadAppSecret= +AzureSettings__ServiceDnsName= +AzureSettings__CertificateThumbprint= +AzureSettings__InstancePublicPort= +AzureSettings__CallSignalingPort=9441 +AzureSettings__InstanceInternalPort=8445 +AzureSettings__PlaceCallEndpointUrl=https://graph.microsoft.com/v1.0 +AzureSettings__CaptureEvents=false +AzureSettings__PodName=bot-0 +AzureSettings__MediaFolder=archive +AzureSettings__EventsFolder=events +AzureSettings__IsStereo=false +AzureSettings__WAVSampleRate= +AzureSettings__WAVQuality=100 diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Program.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Program.cs new file mode 100644 index 000000000..b5ff0da71 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Program.cs @@ -0,0 +1,55 @@ +using RecordingBot.Services.ServiceSetup; +using System; +using System.Diagnostics; +using System.Reflection; + +namespace RecordingBot.Console +{ + public class Program : AppHost + { + public static void Main(string[] args) + { + var info = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location); + + if (args.Length > 0 && args[0].Equals("-v")) + { + System.Console.WriteLine(info.FileVersion); + return; + } + + var bot = new Program(); + + try + { + System.Console.WriteLine("RecordingBot: booting"); + + bot.Boot(args); + } + catch (Exception e) + { + ExceptionHandler(e); + } + } + + public static void ExceptionHandler(Exception e) + { + System.Console.BackgroundColor = ConsoleColor.Black; + System.Console.ForegroundColor = ConsoleColor.DarkRed; + System.Console.WriteLine($"Unhandled exception: {e.Message}"); + System.Console.ForegroundColor = ConsoleColor.White; + System.Console.WriteLine("Exception Details:"); + System.Console.ForegroundColor = ConsoleColor.DarkRed; + InnerExceptionHandler(e.InnerException); + System.Console.ForegroundColor = ConsoleColor.White; + System.Console.WriteLine("press any key to exit..."); + System.Console.ReadKey(); + } + + private static void InnerExceptionHandler(Exception e) + { + if (e == null) return; + System.Console.WriteLine(e.Message); + InnerExceptionHandler(e.InnerException); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/AssemblyInfo.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..8172564da --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/AssemblyInfo.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("RecordingBot.Console")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("RecordingBot.Console")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("aeeb866d-e17b-406f-9385-32273d2f8691")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0")] +[assembly: AssemblyFileVersion("1.0.0")] diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/launchSettings.json b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/launchSettings.json new file mode 100644 index 000000000..34663a19f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "RecordingBot.Console": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:9441;http://localhost:9442" + } + } +} \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/RecordingBot.Console.csproj b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/RecordingBot.Console.csproj new file mode 100644 index 000000000..bd43fa5f3 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Console/RecordingBot.Console.csproj @@ -0,0 +1,21 @@ + + + net8.0 + Exe + false + x64 + x64 + latest + disable + + + + + Always + + + + + + + \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AudioConstants.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AudioConstants.cs new file mode 100644 index 000000000..65fbec9a8 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AudioConstants.cs @@ -0,0 +1,10 @@ +namespace RecordingBot.Model.Constants +{ + public static class AudioConstants + { + public const int DEFAULT_SAMPLE_RATE = 16000; + public const int DEFAULT_BITS = 16; + public const int DEFAULT_CHANNELS = 1; + public const int HIGHEST_SAMPLING_QUALITY_LEVEL = 60; + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AzureConstants.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AzureConstants.cs new file mode 100644 index 000000000..fb377a4d9 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/AzureConstants.cs @@ -0,0 +1,10 @@ +namespace RecordingBot.Model.Constants +{ + public static class AzureConstants + { + // Currently the service does not sign outbound request using AAD, instead it is signed + // with a private certificate. In order for us to be able to ensure the certificate is + // valid we need to download the corresponding public keys from a trusted source. + public const string AUTH_DOMAIN = "https://api.aps.skype.com/v1/.well-known/OpenIdConfiguration"; + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/BotConstants.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/BotConstants.cs new file mode 100644 index 000000000..04adb204c --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/BotConstants.cs @@ -0,0 +1,9 @@ +namespace RecordingBot.Model.Constants +{ + public static class BotConstants + { + public const uint NUMBER_OF_MULTIVIEW_SOCKETS = 3; + public const string DEFAULT_OUTPUT_FOLDER = "teams-recording-bot"; + public const string TOPIC_ENDPOINT = "https://{0}.{1}-1.eventgrid.azure.net/api/events"; + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/HttpRouteConstants.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/HttpRouteConstants.cs new file mode 100644 index 000000000..00f1aefaa --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/HttpRouteConstants.cs @@ -0,0 +1,38 @@ +namespace RecordingBot.Model.Constants +{ + /// + /// HTTP route constants for routing requests to CallController methods. + /// + public static class HttpRouteConstants + { + /// + /// Route prefix for all incoming requests. + /// + public const string CALL_SIGNALING_ROUTE_PREFIX = "api/calling"; + + /// + /// Route for incoming call requests. + /// + public const string ON_INCOMING_REQUEST_ROUTE = ""; + + /// + /// Route for incoming notification requests. + /// + public const string ON_NOTIFICATION_REQUEST_ROUTE = "notification"; + + /// + /// The calls route for both GET and POST. + /// + public const string CALLS = "calls"; + + /// + /// The route for join call. + /// + public const string JOIN_CALLS = "joinCall"; + + /// + /// The route for getting the call. + /// + public const string CALL_ROUTE = CALLS + "/{callLegId}"; + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/SerializerAssemblies.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/SerializerAssemblies.cs new file mode 100644 index 000000000..f828664d5 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Constants/SerializerAssemblies.cs @@ -0,0 +1,37 @@ +using Microsoft.Graph.Communications.Client; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Models; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace RecordingBot.Model.Constants +{ + public static class SerializerAssemblies + { + private static readonly IEnumerable _assemblies = + [ + typeof(Entity).Assembly, + typeof(Error).Assembly, + typeof(CommunicationsClientBuilder).Assembly + ]; + + private static Assembly[] _distinctAssemblies = null; + + public static Assembly[] Assemblies + { + get + { + if (_distinctAssemblies == null) + { + _distinctAssemblies = _assemblies + .Where(assembly => assembly != null) + .Distinct() + .ToArray(); + } + + return _distinctAssemblies; + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Contracts/IInitializable.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Contracts/IInitializable.cs new file mode 100644 index 000000000..5e477c4be --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Contracts/IInitializable.cs @@ -0,0 +1,7 @@ +namespace RecordingBot.Model.Contracts +{ + public interface IInitializable + { + void Initialize(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Extension/HttpRequestExtensions.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Extension/HttpRequestExtensions.cs new file mode 100644 index 000000000..a1332c00f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Extension/HttpRequestExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Buffers; + +namespace RecordingBot.Model.Extension +{ + public static class HttpRequestExtensions + { + private const string UNKOWN_HOST = "UNKNOWN-HOST"; + private const string MULTIPLE_HOSTS = "MULTIPLE-HOST"; + private static readonly SearchValues CommaSearch = SearchValues.Create([',']); + + public static Uri GetUri(this HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request, nameof(request)); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Scheme, nameof(request.Scheme)); + + return new Uri(GetUrl(request)); + } + + public static string GetUrl(this HttpRequest request) + { + if (request == null) + { + return string.Empty; + } + + if (!request.Host.HasValue) + { + return $"{request.Scheme}://{UNKOWN_HOST}{request.Path}{request.QueryString}"; + } + if (request.Host.Value.AsSpan().ContainsAny(CommaSearch)) + { + return $"{request.Scheme}://{MULTIPLE_HOSTS}{request.Path}{request.QueryString}"; + } + + return $"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}{request.QueryString}"; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/BotEventData.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/BotEventData.cs new file mode 100644 index 000000000..b64ea6c67 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/BotEventData.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace RecordingBot.Model.Models +{ + public class BotEventData + { + [JsonProperty(PropertyName = "message")] + public string Message { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinCallBody.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinCallBody.cs new file mode 100644 index 000000000..3763896ae --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinCallBody.cs @@ -0,0 +1,17 @@ +namespace RecordingBot.Model.Models +{ + public class JoinCallBody + { + public string JoinURL { get; set; } + /// + /// Gets or sets the display name. + /// Teams client does not allow changing of ones own display name. + /// If display name is specified, we join as anonymous (guest) user + /// with the specified display name. This will put bot into lobby + /// unless lobby bypass is disabled. + /// Side note: if display name is specified, the bot will not have + /// access to UnmixedAudioBuffer in the Skype Media libraries. + /// + public string DisplayName { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinURLResponse.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinURLResponse.cs new file mode 100644 index 000000000..c5c7e6e4f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/JoinURLResponse.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; + +namespace RecordingBot.Model.Models +{ + public partial class JoinURLResponse + { + [JsonProperty("callId")] + public object CallId { get; set; } + + [JsonProperty("scenarioId")] + public Guid ScenarioId { get; set; } + + [JsonProperty("call")] + public string Call { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/Meeting.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/Meeting.cs new file mode 100644 index 000000000..e48c00bf6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/Meeting.cs @@ -0,0 +1,17 @@ +using System.Runtime.Serialization; + +namespace RecordingBot.Model.Models +{ + [DataContract] + public class Meeting + { + [DataMember] + public string Tid { get; set; } + + [DataMember] + public string Oid { get; set; } + + [DataMember] + public string MessageId { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipant.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipant.cs new file mode 100644 index 000000000..3ee9c5d0d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipant.cs @@ -0,0 +1,137 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Client; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Graph.Communications.Common.Transport; +using Microsoft.Graph.Communications.Resources; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Serialization; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.Model.Models +{ + public class SerilizableParticipant : IParticipant, IParsable + { + public Participant Resource { get; set; } + + public DateTimeOffset ModifiedDateTime { get; set; } + public DateTimeOffset CreatedDateTime { get; set; } + public string ResourcePath { get; set; } + + public string Id { get; set; } + [JsonIgnore] + object IResource.Resource { get; } + [JsonIgnore] + public ICommunicationsClient Client { get; set; } + [JsonIgnore] + public IGraphClient GraphClient { get; set; } + [JsonIgnore] + public IGraphLogger GraphLogger { get; set; } + + [JsonConstructor] + public SerilizableParticipant(IParticipant participant) + { + if (participant != null) + { + Resource = participant.Resource; + ResourcePath = participant.ResourcePath; + ModifiedDateTime = participant.ModifiedDateTime; + CreatedDateTime = participant.CreatedDateTime; + } + } + + public SerilizableParticipant(Participant participant) + { + Resource = participant; + } + + public SerilizableParticipant() + { } + +#pragma warning disable CS0067 // The event 'SerilizableParticipant.OnUpdated' is never used + public event ResourceEventHandler OnUpdated; +#pragma warning restore CS0067 // The event 'SerilizableParticipant.OnUpdated' is never used + + public IDictionary> GetFieldDeserializers() + { + return new Dictionary>() + { + { + "resource", + delegate(IParseNode n) + { + Resource = n.GetObjectValue(Participant.CreateFromDiscriminatorValue); + } + }, + { + "modifiedDateTime", + delegate(IParseNode n) + { + ModifiedDateTime = n.GetDateTimeOffsetValue() ?? default; + } + }, + { + "createdDateTime", + delegate(IParseNode n) + { + CreatedDateTime = n.GetDateTimeOffsetValue() ?? default; + } + }, + { + "resourcePath", + delegate(IParseNode n) + { + ResourcePath = n.GetStringValue(); + } + } + }; + } + + public void Serialize(ISerializationWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + + writer.WriteObjectValue("resource", Resource); + writer.WriteDateTimeOffsetValue("modifiedDateTime", ModifiedDateTime); + writer.WriteDateTimeOffsetValue("createdDateTime", CreatedDateTime); + writer.WriteStringValue("ResourcePath", ResourcePath); + } + + public static SerilizableParticipant CreateFromDiscriminatorValue(IParseNode parseNode) + { + ArgumentNullException.ThrowIfNull(parseNode); + + return new SerilizableParticipant(); + } + + public Task MuteAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task StartHoldMusicAsync(Prompt customPrompt, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task StopHoldMusicAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(bool handleHttpNotFoundInternally = false, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + throw new NotImplementedException(); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipantEvent.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipantEvent.cs new file mode 100644 index 000000000..a51d2ea7e --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Models/SerializableParticipantEvent.cs @@ -0,0 +1,50 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Kiota.Abstractions.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RecordingBot.Model.Models +{ + public class SerializableParticipantEvent : IParsable + { + public List AddedResources { get; set; } + public List RemovedResources { get; set; } + + public IDictionary> GetFieldDeserializers() + { + return new Dictionary>() + { + { + "addedResources", + delegate(IParseNode n) + { + AddedResources = n.GetCollectionOfObjectValues(SerilizableParticipant.CreateFromDiscriminatorValue).Cast().ToList(); + } + }, + { + "removedResources", + delegate(IParseNode n) + { + RemovedResources = n.GetCollectionOfObjectValues(SerilizableParticipant.CreateFromDiscriminatorValue).Cast().ToList(); + } + } + }; + } + + public void Serialize(ISerializationWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + + writer.WriteCollectionOfObjectValues("addedResources", AddedResources.Cast()); + writer.WriteCollectionOfObjectValues("removedResources", RemovedResources.Cast()); + } + + public static SerializableParticipantEvent CreateFromDiscriminatorValue(IParseNode parseNode) + { + ArgumentNullException.ThrowIfNull(parseNode); + + return new SerializableParticipantEvent(); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Properties/AssemblyInfo.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..4d09135c5 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("RecordingBot.Model")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("RecordingBot.Model")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("78fd9ad4-6da2-4610-9c3c-20ce1b2396e6")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/RecordingBot.Model.csproj b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/RecordingBot.Model.csproj new file mode 100644 index 000000000..bcf1f79cd --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/RecordingBot.Model.csproj @@ -0,0 +1,19 @@ + + + net8.0 + Library + false + x64 + x64 + latest + disable + + + + + + + + + + \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/RecordingBotSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/RecordingBotSettings.cs new file mode 100644 index 000000000..33ba612c6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/RecordingBotSettings.cs @@ -0,0 +1,7 @@ +namespace RecordingBot.Model.Settings +{ + public class RecordingBotSettings + { + public int ServicePointManagerDefaultConnectionLimit { get; set; } = 12; + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/WavFileSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/WavFileSettings.cs new file mode 100644 index 000000000..45935e88a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Model/Settings/WavFileSettings.cs @@ -0,0 +1,27 @@ +namespace RecordingBot.Model.Settings +{ + /// + /// wav file writer, this class will create a wav file + /// from the received buffers in the smart agents. + /// + public class WavFileSettings + { + /// + /// Initializes a new instance of the class. + /// Default constructor with default PCM 16 mono. + /// This class was taken and adapted from + /// + public WavFileSettings() + { + CompressionCode = 1; // PCM + NumberOfChannels = 1; // No Stereo + SampleRate = 16000; // 16khz only + AvgBytesPerSecond = 32000; + } + + public short CompressionCode { get; set; } + public short NumberOfChannels { get; set; } + public int SampleRate { get; set; } + public int AvgBytesPerSecond { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/AuthenticationProvider.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/AuthenticationProvider.cs new file mode 100644 index 000000000..e905049ba --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/AuthenticationProvider.cs @@ -0,0 +1,183 @@ +using Microsoft.Graph.Communications.Client.Authentication; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Identity.Client; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using RecordingBot.Model.Constants; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Authentication +{ + public class AuthenticationProvider : ObjectRoot, IRequestAuthenticationProvider + { + private OpenIdConnectConfiguration _openIdConfiguration; + private readonly ConfidentialClientApplicationOptions _clientOptions; + private static readonly IEnumerable _defaultScopes = ["https://graph.microsoft.com/.default"]; + + private readonly TimeSpan _openIdConfigRefreshInterval = TimeSpan.FromHours(2); + private DateTime _prevOpenIdConfigUpdateTimestamp = DateTime.MinValue; + + public AuthenticationProvider(string appName, string appId, string appSecret, IGraphLogger logger) + : base(logger.NotNull(nameof(logger)).CreateShim(nameof(AuthenticationProvider))) + { + _clientOptions = new ConfidentialClientApplicationOptions + { + ClientName = appName.NotNullOrWhitespace(nameof(appName)), + ClientId = appId.NotNullOrWhitespace(nameof(appId)), + ClientSecret = appSecret.NotNullOrWhitespace(nameof(appSecret)) + }; + } + + /// + /// Authenticates the specified request message. + /// This method will be called any time there is an outbound request. + /// In this case we are using the Microsoft.IdentityModel.Clients.ActiveDirectory library + /// to stamp the outbound http request with the OAuth 2.0 token using an AAD application id + /// and application secret. Alternatively, this method can support certificate validation. + /// + /// The request. + /// The tenant. + /// The . + public async Task AuthenticateOutboundRequestAsync(HttpRequestMessage request, string tenant) + { + const string schema = "Bearer"; + + // If no tenant was specified, we craft the token link using the common tenant. + // https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints + tenant = string.IsNullOrWhiteSpace(tenant) ? "common" : tenant; + + GraphLogger.Info("AuthenticationProvider: Generating OAuth token."); + + var clientApp = ConfidentialClientApplicationBuilder.CreateWithApplicationOptions(_clientOptions).WithTenantId(tenant).Build(); + + AuthenticationResult result; + try + { + result = await AcquireTokenWithRetryAsync(clientApp, attempts: 3); + } + catch (Exception ex) + { + GraphLogger.Error(ex, $"Failed to generate token for client: {_clientOptions.ClientId}"); + throw; + } + + GraphLogger.Info($"AuthenticationProvider: Generated OAuth token. Expires in {result.ExpiresOn.Subtract(DateTimeOffset.UtcNow).TotalMinutes} minutes."); + + request.Headers.Authorization = new AuthenticationHeaderValue(schema, result.AccessToken); + } + + /// + /// Validates the request asynchronously. + /// This method will be called any time we have an incoming request. + /// Returning invalid result will trigger a Forbidden response. + /// + public async Task ValidateInboundRequestAsync(HttpRequestMessage request) + { + var token = request?.Headers?.Authorization?.Parameter; + if (string.IsNullOrWhiteSpace(token)) + { + return new RequestValidationResult { IsValid = false }; + } + + // Currently the service does not sign outbound request using AAD, instead it is signed + // with a private certificate. In order for us to be able to ensure the certificate is + // valid we need to download the corresponding public keys from a trusted source. + const string authDomain = AzureConstants.AUTH_DOMAIN; + if (_openIdConfiguration == null || DateTime.Now > _prevOpenIdConfigUpdateTimestamp.Add(_openIdConfigRefreshInterval)) + { + GraphLogger.Info("Updating OpenID configuration"); + + // Download the OIDC configuration which contains the JWKS + ConfigurationManager configurationManager = + new(authDomain, + new OpenIdConnectConfigurationRetriever()); + _openIdConfiguration = await configurationManager.GetConfigurationAsync(CancellationToken.None).ConfigureAwait(false); + + _prevOpenIdConfigUpdateTimestamp = DateTime.Now; + } + + // The incoming token should be issued by graph. + var authIssuers = new[] + { + "https://graph.microsoft.com", + "https://api.botframework.com", + }; + + // Configure the TokenValidationParameters. + // Aet the Issuer(s) and Audience(s) to validate and + // assign the SigningKeys which were downloaded from AuthDomain. + TokenValidationParameters validationParameters = new() + { + ValidIssuers = authIssuers, + ValidAudience = _clientOptions.ClientId, + IssuerSigningKeys = _openIdConfiguration.SigningKeys, + }; + + ClaimsPrincipal claimsPrincipal; + try + { + // Now validate the token. If the token is not valid for any reason, an exception will be thrown by the method + JwtSecurityTokenHandler handler = new(); + claimsPrincipal = handler.ValidateToken(token, validationParameters, out _); + } + + // Token expired... should somehow return 401 (Unauthorized) + // catch (SecurityTokenExpiredException ex) + // Tampered token + // catch (SecurityTokenInvalidSignatureException ex) + // Some other validation error + // catch (SecurityTokenValidationException ex) + catch (Exception ex) + { + // Some other error + GraphLogger.Error(ex, $"Failed to validate token for client: {_clientOptions.ClientId}."); + return new RequestValidationResult() { IsValid = false }; + } + + const string ClaimType = "http://schemas.microsoft.com/identity/claims/tenantid"; + var tenantClaim = claimsPrincipal.FindFirst(claim => claim.Type.Equals(ClaimType, StringComparison.Ordinal)); + + if (string.IsNullOrEmpty(tenantClaim?.Value)) + { + // No tenant claim given to us. reject the request. + return new RequestValidationResult { IsValid = false }; + } + + return new RequestValidationResult { IsValid = true, TenantId = tenantClaim.Value }; + } + + /// + /// Acquires the token and retries if failure occurs. + /// + private static async Task AcquireTokenWithRetryAsync(IConfidentialClientApplication context, int attempts) + { + while (true) + { + attempts--; + + try + { + return await context.AcquireTokenForClient(_defaultScopes).ExecuteAsync(); + } + catch (Exception) + { + if (attempts < 1) + { + throw; + } + } + + await Task.Delay(1000); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/UserPasswordAuthenticationProvider.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/UserPasswordAuthenticationProvider.cs new file mode 100644 index 000000000..38ce01252 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Authentication/UserPasswordAuthenticationProvider.cs @@ -0,0 +1,90 @@ +using Microsoft.Graph.Communications.Client.Authentication; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using Newtonsoft.Json; +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Authentication +{ + public class UserPasswordAuthenticationProvider : ObjectRoot, IRequestAuthenticationProvider + { + private readonly string _appName; + private readonly string _appId; + private readonly string _appSecret; + private readonly string _userName; + private readonly string _password; + + public UserPasswordAuthenticationProvider(string appName, string appId, string appSecret, string userName, string password, IGraphLogger logger) + : base(logger.NotNull(nameof(logger)).CreateShim(nameof(UserPasswordAuthenticationProvider))) + { + _appName = appName.NotNullOrWhitespace(nameof(appName)); + _appId = appId.NotNullOrWhitespace(nameof(appId)); + _appSecret = appSecret.NotNullOrWhitespace(nameof(appSecret)); + + _userName = userName.NotNullOrWhitespace(nameof(userName)); + _password = password.NotNullOrWhitespace(nameof(password)); + } + + /// + public async Task AuthenticateOutboundRequestAsync(HttpRequestMessage request, string tenantId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(tenantId), $"Invalid {nameof(tenantId)}."); + + const string BearerPrefix = "Bearer"; + const string ReplaceString = "{tenant}"; + const string TokenAuthorityMicrosoft = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"; + const string Resource = @"https://graph.microsoft.com/.default"; + + var tokenLink = TokenAuthorityMicrosoft.Replace(ReplaceString, tenantId); + OAuthResponse authResult = null; + + try + { + using var httpClient = new HttpClient(); + var result = await httpClient.PostAsync(tokenLink, new FormUrlEncodedContent( + [ + new("grant_type", "password"), + new("username", _userName), + new("password", _password), + new("scope", Resource), + new("client_id", _appId), + new("client_secret", _appSecret), + ])).ConfigureAwait(false); + + if (!result.IsSuccessStatusCode) + { + throw new Exception("Failed to generate user token."); + } + + var content = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + authResult = JsonConvert.DeserializeObject(content); + + request.Headers.Authorization = new AuthenticationHeaderValue(BearerPrefix, authResult.Access_Token); + } + catch (Exception ex) + { + GraphLogger.Error(ex, $"Failed to generate user token for user: {_userName}"); + throw; + } + + GraphLogger.Info($"Generated OAuth token. Expires in {authResult.Expires_In / 60} minutes."); + } + + /// + public Task ValidateInboundRequestAsync(HttpRequestMessage request) + { + // Currently no scenarios on /user | /me path for inbound requests. + throw new NotImplementedException(); + } + + private class OAuthResponse + { + public string Access_Token { get; set; } + public int Expires_In { get; set; } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotMediaStream.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotMediaStream.cs new file mode 100644 index 000000000..f93c11c24 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotMediaStream.cs @@ -0,0 +1,101 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Calls.Media; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Skype.Bots.Media; +using RecordingBot.Services.Contract; +using RecordingBot.Services.Media; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Bot +{ + /// + /// Class responsible for streaming audio and video. + /// + public class BotMediaStream : ObjectRootDisposable + { + internal List participants; + private readonly IAudioSocket _audioSocket; + private readonly MediaStream _mediaStream; + private readonly IEventPublisher _eventPublisher; + private readonly string _callId; + public SerializableAudioQualityOfExperienceData AudioQualityOfExperienceData { get; private set; } + + public BotMediaStream( + ILocalMediaSession mediaSession, + string callId, + IGraphLogger logger, + IEventPublisher eventPublisher, + IAzureSettings settings) : base(logger) + { + ArgumentNullException.ThrowIfNull(mediaSession, nameof(mediaSession)); + ArgumentNullException.ThrowIfNull(logger, nameof(logger)); + ArgumentNullException.ThrowIfNull(settings, nameof(settings)); + + participants = []; + + _eventPublisher = eventPublisher; + _callId = callId; + _mediaStream = new MediaStream(settings, logger, mediaSession.MediaSessionId.ToString()); + + // Subscribe to the audio media. + _audioSocket = mediaSession.AudioSocket; + if (_audioSocket == null) + { + throw new InvalidOperationException("A mediaSession needs to have at least an audioSocket"); + } + + _audioSocket.AudioMediaReceived += OnAudioMediaReceived; + } + + public List GetParticipants() + { + return participants; + } + + public SerializableAudioQualityOfExperienceData GetAudioQualityOfExperienceData() + { + AudioQualityOfExperienceData = new SerializableAudioQualityOfExperienceData(_callId, _audioSocket.GetQualityOfExperienceData()); + return AudioQualityOfExperienceData; + } + + public async Task StopMedia() + { + await _mediaStream.End(); + // Event - Stop media occurs when the call stops recording + _eventPublisher.Publish("StopMediaStream", "Call stopped recording"); + } + + /// + protected override void Dispose(bool disposing) + { + // Event Dispose of the bot media stream object + _eventPublisher.Publish("MediaStreamDispose", disposing.ToString()); + + base.Dispose(disposing); + + _audioSocket.AudioMediaReceived -= OnAudioMediaReceived; + } + + private async void OnAudioMediaReceived(object sender, AudioMediaReceivedEventArgs e) + { + GraphLogger.Info($"Received Audio: [AudioMediaReceivedEventArgs(Data=<{e.Buffer.Data}>, Length={e.Buffer.Length}, Timestamp={e.Buffer.Timestamp})]"); + + try + { + await _mediaStream.AppendAudioBuffer(e.Buffer, participants); + e.Buffer.Dispose(); + } + catch (Exception ex) + { + GraphLogger.Error(ex); + } + finally + { + e.Buffer.Dispose(); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotService.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotService.cs new file mode 100644 index 000000000..23e81a6b2 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/BotService.cs @@ -0,0 +1,193 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Calls.Media; +using Microsoft.Graph.Communications.Client; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Graph.Communications.Resources; +using Microsoft.Graph.Contracts; +using Microsoft.Graph.Models; +using Microsoft.Skype.Bots.Media; +using RecordingBot.Model.Models; +using RecordingBot.Services.Authentication; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using RecordingBot.Services.Util; +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Bot +{ + public class BotService : IDisposable, IBotService + { + private readonly IGraphLogger _logger; + private readonly IEventPublisher _eventPublisher; + private readonly AzureSettings _settings; + + public ConcurrentDictionary CallHandlers { get; } = []; + private ICommunicationsClient _client; + public ICommunicationsClient Client + { + get + { + if (_client == null) + { + InitializeClient(); + } + + return _client; + } + } + + public BotService(IGraphLogger logger, IEventPublisher eventPublisher, IAzureSettings settings) + { + _logger = logger; + _eventPublisher = eventPublisher; + _settings = (AzureSettings)settings; + } + + public void Initialize() + { + InitializeClient(); + RegisterCallEventHandlers(); + } + + private void InitializeClient() + { + var name = GetType().Assembly.GetName().Name; + var builder = new CommunicationsClientBuilder(name, _settings.AadAppId, _logger); + var authProvider = new AuthenticationProvider(name, _settings.AadAppId, _settings.AadAppSecret, _logger); + + builder.SetAuthenticationProvider(authProvider); + builder.SetNotificationUrl(_settings.CallControlBaseUrl); + builder.SetMediaPlatformSettings(_settings.MediaPlatformSettings); + builder.SetServiceBaseUrl(_settings.PlaceCallEndpointUrl); + + _client = builder.Build(); + } + + private void RegisterCallEventHandlers() + { + Client.Calls().OnIncoming += CallsOnIncoming; + Client.Calls().OnUpdated += CallsOnUpdated; + } + + public async Task EndCallByCallLegIdAsync(string callLegId) + { + try + { + await GetHandlerOrThrow(callLegId).Call.DeleteAsync().ConfigureAwait(false); + } + catch (Exception) + { + // Manually remove the call from SDK state. + // This will trigger the ICallCollection.OnUpdated event with the removed resource. + Client.Calls().TryForceRemove(callLegId, out ICall _); + } + } + + public async Task JoinCallAsync(JoinCallBody joinCallBody) + { + // A tracking id for logging purposes. Helps identify this call in logs. + var scenarioId = Guid.NewGuid(); + + var (chatInfo, meetingInfo) = JoinInfo.ParseJoinURL(joinCallBody.JoinURL); + + var tenantId = (meetingInfo as OrganizerMeetingInfo).Organizer.GetPrimaryIdentity().GetTenantId(); + var mediaSession = CreateLocalMediaSession(); + + var joinParams = new JoinMeetingParameters(chatInfo, meetingInfo, mediaSession) + { + TenantId = tenantId, + }; + + if (!string.IsNullOrWhiteSpace(joinCallBody.DisplayName)) + { + // Teams client does not allow changing of ones own display name. + // If display name is specified, we join as anonymous (guest) user + // with the specified display name. This will put bot into lobby + // unless lobby bypass is disabled. + joinParams.GuestIdentity = new Identity + { + Id = Guid.NewGuid().ToString(), + DisplayName = joinCallBody.DisplayName, + }; + } + + var statefulCall = await Client.Calls().AddAsync(joinParams, scenarioId).ConfigureAwait(false); + statefulCall.GraphLogger.Info($"Call creation complete: {statefulCall.Id}"); + + return statefulCall; + } + + private ILocalMediaSession CreateLocalMediaSession(Guid mediaSessionId = default) + { + try + { + // create media session object, this is needed to establish call connections + return Client.CreateMediaSession( + new AudioSocketSettings + { + StreamDirections = StreamDirection.Recvonly, + // Note! Currently, the only audio format supported when receiving unmixed audio is Pcm16K + SupportedAudioFormat = AudioFormat.Pcm16K, + ReceiveUnmixedMeetingAudio = true //get the extra buffers for the speakers + }, + new VideoSocketSettings + { + StreamDirections = StreamDirection.Inactive + }, + mediaSessionId: mediaSessionId); + } + catch (Exception e) + { + _logger.Log(System.Diagnostics.TraceLevel.Error, e.Message); + throw; + } + } + + private void CallsOnIncoming(ICallCollection sender, CollectionEventArgs args) + { + args.AddedResources.ForEach(call => + { + IMediaSession mediaSession = Guid.TryParse(call.Id, out Guid callId) ? CreateLocalMediaSession(callId) : CreateLocalMediaSession(); + call?.AnswerAsync(mediaSession).ForgetAndLogExceptionAsync(call.GraphLogger, $"Answering call {call.Id} with scenario {call.ScenarioId}."); + }); + } + + private void CallsOnUpdated(ICallCollection sender, CollectionEventArgs args) + { + foreach (var call in args.AddedResources) + { + CallHandlers[call.Id] = new CallHandler(call, _settings, _eventPublisher); + } + + foreach (var call in args.RemovedResources) + { + if (CallHandlers.TryRemove(call.Id, out CallHandler handler)) + { + handler.Dispose(); + } + } + } + + private CallHandler GetHandlerOrThrow(string callLegId) + { + if (!CallHandlers.TryGetValue(callLegId, out CallHandler handler)) + { + throw new ArgumentException($"call ({callLegId}) not found"); + } + + return handler; + } + + /// + public void Dispose() + { + _client?.Dispose(); + _client = null; + + GC.SuppressFinalize(this); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/CallHandler.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/CallHandler.cs new file mode 100644 index 000000000..9a6544b9d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/CallHandler.cs @@ -0,0 +1,233 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Calls.Media; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Graph.Communications.Resources; +using Microsoft.Graph.Models; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using RecordingBot.Services.Util; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using System.Timers; + +namespace RecordingBot.Services.Bot +{ + public class CallHandler : HeartbeatHandler + { + private int _recordingStatusIndex = -1; + private readonly AzureSettings _settings; + private readonly IEventPublisher _eventPublisher; + private readonly CaptureEvents _capture; + private bool _isDisposed = false; + + public ICall Call { get; } + public BotMediaStream BotMediaStream { get; private set; } + + public CallHandler(ICall statefulCall, IAzureSettings settings, IEventPublisher eventPublisher) : base(TimeSpan.FromMinutes(10), statefulCall?.GraphLogger) + { + _settings = (AzureSettings)settings; + _eventPublisher = eventPublisher; + + Call = statefulCall; + Call.OnUpdated += CallOnUpdated; + Call.Participants.OnUpdated += ParticipantsOnUpdated; + + BotMediaStream = new BotMediaStream(Call.GetLocalMediaSession(), Call.Id, GraphLogger, eventPublisher, _settings); + + if (_settings.CaptureEvents) + { + var path = Path.Combine(Path.GetTempPath(), BotConstants.DEFAULT_OUTPUT_FOLDER, _settings.EventsFolder, statefulCall.GetLocalMediaSession().MediaSessionId.ToString(), "participants"); + _capture = new CaptureEvents(path); + } + } + + /// + protected override Task HeartbeatAsync(ElapsedEventArgs args) + { + return Call.KeepAliveAsync(); + } + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _isDisposed = true; + Call.OnUpdated -= CallOnUpdated; + Call.Participants.OnUpdated -= ParticipantsOnUpdated; + + BotMediaStream?.Dispose(); + + // Event - Dispose of the call completed ok + _eventPublisher.Publish("CallDisposedOK", $"Call.Id: {Call.Id}"); + } + + private void OnRecordingStatusFlip(ICall source) + { + _ = Task.Run(async () => + { + // TODO: consider rewriting the recording status checking + var recordingStatus = new[] { RecordingStatus.Recording, RecordingStatus.NotRecording, RecordingStatus.Failed }; + + var recordingIndex = _recordingStatusIndex + 1; + if (recordingIndex >= recordingStatus.Length) + { + var recordedParticipantId = Call.Resource.IncomingContext.ObservedParticipantId; + + var recordedParticipant = Call.Participants[recordedParticipantId]; + await recordedParticipant.DeleteAsync().ConfigureAwait(false); + // Event - Recording has ended + _eventPublisher.Publish("CallRecordingFlip", $"Call.Id: {Call.Id} ended"); + return; + } + + var newStatus = recordingStatus[recordingIndex]; + try + { + // Event - Log the recording status + var status = Enum.GetName(typeof(RecordingStatus), newStatus); + _eventPublisher.Publish("CallRecordingFlip", $"Call.Id: {Call.Id} status changed to {status}"); + + // NOTE: if your implementation supports stopping the recording during the call, you can call the same method above with RecordingStatus.NotRecording + await source + .UpdateRecordingStatusAsync(newStatus) + .ConfigureAwait(false); + + _recordingStatusIndex = recordingIndex; + } + catch (Exception exc) + { + // e.g. bot joins via direct join - may not have the permissions + GraphLogger.Error(exc, $"Failed to flip the recording status to {newStatus}"); + // Event - Recording status exception - failed to update + _eventPublisher.Publish("CallRecordingFlip", $"Failed to flip the recording status to {newStatus}"); + } + }).ForgetAndLogExceptionAsync(GraphLogger); + } + + private async void CallOnUpdated(ICall sender, ResourceEventArgs e) + { + GraphLogger.Info($"Call status updated to {e.NewResource.State} - {e.NewResource.ResultInfo?.Message}"); + + // Event - Recording update e.g established/updated/start/ended + _eventPublisher.Publish($"Call{e.NewResource.State}", $"Call.ID {Call.Id} Sender.Id {sender.Id} status updated to {e.NewResource.State} - {e.NewResource.ResultInfo?.Message}"); + + if (e.OldResource.State != e.NewResource.State && e.NewResource.State == CallState.Established) + { + if (!_isDisposed) + { + // Call is established. We should start receiving Audio, we can inform clients that we have started recording. + OnRecordingStatusFlip(sender); + } + } + + if ((e.OldResource.State == CallState.Established) && (e.NewResource.State == CallState.Terminated)) + { + if (BotMediaStream != null) + { + var aQoE = BotMediaStream.GetAudioQualityOfExperienceData(); + + if (aQoE != null && _settings.CaptureEvents) + { + await _capture?.Append(aQoE); + } + await BotMediaStream.StopMedia(); + } + + if (_settings.CaptureEvents) + { + await _capture?.Finalize(); + } + } + } + + private static string CreateParticipantUpdateJson(string participantId, string participantDisplayName = "") + { + StringBuilder stringBuilder = new(); + + stringBuilder.Append('{'); + stringBuilder.AppendFormat("\"Id\": \"{0}\"", participantId); + + if (!string.IsNullOrWhiteSpace(participantDisplayName)) + { + stringBuilder.AppendFormat(", \"DisplayName\": \"{0}\"", participantDisplayName); + } + + stringBuilder.Append('}'); + + return stringBuilder.ToString(); + } + + private static string UpdateParticipant(List participants, IParticipant participant, bool added, string participantDisplayName = "") + { + if (added) + participants.Add(participant); + else + participants.Remove(participant); + return CreateParticipantUpdateJson(participant.Id, participantDisplayName); + } + + private void UpdateParticipants(ICollection eventArgs, bool added = true) + { + foreach (var participant in eventArgs) + { + var json = string.Empty; + + // todo remove the cast with the new graph implementation, + // for now we want the bot to only subscribe to "real" participants + var participantDetails = participant.Resource.Info.Identity.User; + + if (participantDetails != null) + { + json = UpdateParticipant(BotMediaStream.participants, participant, added, participantDetails.DisplayName); + } + else if (participant.Resource.Info.Identity.AdditionalData?.Count > 0) + { + if (CheckParticipantIsUsable(participant)) + { + json = UpdateParticipant(BotMediaStream.participants, participant, added); + } + } + + if (json.Length > 0) + { + if (added) + { + _eventPublisher.Publish("CallParticipantAdded", json); + } + else + { + _eventPublisher.Publish("CallParticipantRemoved", json); + } + } + } + } + + public void ParticipantsOnUpdated(IParticipantCollection sender, CollectionEventArgs args) + { + if (_settings.CaptureEvents) + { + _capture?.Append(args); + } + + UpdateParticipants(args.AddedResources); + UpdateParticipants(args.RemovedResources, false); + } + + private static bool CheckParticipantIsUsable(IParticipant p) + { + foreach (var i in p.Resource.Info.Identity.AdditionalData) + { + if (i.Key != "applicationInstance" && i.Value is Identity) + { + return true; + } + } + + return false; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/ExceptionExtensions.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/ExceptionExtensions.cs new file mode 100644 index 000000000..2415f735b --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/ExceptionExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.Graph.Communications.Common.Telemetry; +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Bot +{ + /// + /// Extension methods for Exception. + /// + public static class ExceptionExtensions + { + /// + /// Extension for Task to execute the task in background and log any exception. + /// + /// Task to execute and capture any exceptions. + /// Graph logger. + /// Friendly description of the task for debugging purposes. + /// Calling function. + /// File name where code is located. + /// Line number where code is located. + /// A representing the asynchronous operation. + public static async Task ForgetAndLogExceptionAsync( + this Task task, + IGraphLogger logger, + string description = null, + [CallerMemberName] string memberName = null, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = 0) + { + try + { + await task.ConfigureAwait(false); + logger?.Verbose( + $"Completed running task successfully: {description ?? string.Empty}", + memberName: memberName, + filePath: filePath, + lineNumber: lineNumber); + } + catch (Exception e) + { + // Log and absorb all exceptions here. + logger?.Error( + e, + $"Caught an Exception running the task: {description ?? string.Empty}", + memberName: memberName, + filePath: filePath, + lineNumber: lineNumber); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/HeartbeatHandler.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/HeartbeatHandler.cs new file mode 100644 index 000000000..1e6512e8b --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Bot/HeartbeatHandler.cs @@ -0,0 +1,47 @@ +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using System; +using System.Threading.Tasks; +using System.Timers; + +namespace RecordingBot.Services.Bot +{ + public abstract class HeartbeatHandler : ObjectRootDisposable + { + private readonly Timer _heartbeatTimer; + + public HeartbeatHandler(TimeSpan frequency, IGraphLogger logger) + : base(logger) + { + // initialize the timer + _heartbeatTimer = new Timer(frequency.TotalMilliseconds) + { + Enabled = true, + AutoReset = true, + }; + + _heartbeatTimer.Elapsed += HeartbeatDetected; + } + + protected abstract Task HeartbeatAsync(ElapsedEventArgs args); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + _heartbeatTimer.Elapsed -= HeartbeatDetected; + _heartbeatTimer.Stop(); + _heartbeatTimer.Dispose(); + } + + private void HeartbeatDetected(object sender, ElapsedEventArgs args) + { + var task = $"{GetType().FullName}.{nameof(HeartbeatAsync)}(args)"; + + GraphLogger.Verbose($"Starting running task: " + task); + + _ = Task.Run(() => HeartbeatAsync(args)).ForgetAndLogExceptionAsync(GraphLogger, task); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IAzureSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IAzureSettings.cs new file mode 100644 index 000000000..acedf8b6a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IAzureSettings.cs @@ -0,0 +1,5 @@ +namespace RecordingBot.Services.Contract +{ + public interface IAzureSettings: Model.Contracts.IInitializable + { } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IBotService.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IBotService.cs new file mode 100644 index 000000000..fffe81b54 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IBotService.cs @@ -0,0 +1,17 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Client; +using RecordingBot.Model.Models; +using RecordingBot.Services.Bot; +using System.Collections.Concurrent; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Contract +{ + public interface IBotService : Model.Contracts.IInitializable + { + ConcurrentDictionary CallHandlers { get; } + ICommunicationsClient Client { get; } + Task EndCallByCallLegIdAsync(string callLegId); + Task JoinCallAsync(JoinCallBody joinCallBody); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IEventGridPublisher.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IEventGridPublisher.cs new file mode 100644 index 000000000..d3dbf8c92 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IEventGridPublisher.cs @@ -0,0 +1,7 @@ +namespace RecordingBot.Services.Contract +{ + public interface IEventPublisher + { + void Publish(string Subject, string Message, string TopicName = ""); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IInitializable.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IInitializable.cs new file mode 100644 index 000000000..cea1644be --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IInitializable.cs @@ -0,0 +1,7 @@ +namespace RecordingBot.Services.Contract +{ + public interface IInitializable + { + void Initialize(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IMediaStream.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IMediaStream.cs new file mode 100644 index 000000000..d5bc4cd17 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IMediaStream.cs @@ -0,0 +1,13 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Skype.Bots.Media; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Contract +{ + public interface IMediaStream + { + Task AppendAudioBuffer(AudioMediaBuffer buffer, List participant); + Task End(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IServiceHost.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IServiceHost.cs new file mode 100644 index 000000000..39b5c3199 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Contract/IServiceHost.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RecordingBot.Services.ServiceSetup; +using System; + +namespace RecordingBot.Services.Contract +{ + public interface IServiceHost + { + IServiceCollection Services { get; } + IServiceProvider ServiceProvider { get; } + ServiceHost Configure(IServiceCollection services, IConfiguration configuration); + IServiceProvider Build(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/DemoController.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/DemoController.cs new file mode 100644 index 000000000..6228c1b5b --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/DemoController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph.Communications.Common.Telemetry; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Http.Controllers +{ + /// + /// DemoController serves as the gateway to explore the bot. + /// From here you can get a list of calls, and functions for each call. + /// + [ApiController] + public class DemoController : Controller + { + private readonly IGraphLogger _logger; + private readonly IBotService _botService; + private readonly AzureSettings _settings; + private readonly IEventPublisher _eventPublisher; + + public DemoController(IGraphLogger logger, IEventPublisher eventPublisher, IBotService botService, AzureSettings azureSettings) + { + _logger = logger; + _eventPublisher = eventPublisher; + _botService = botService; + _settings = azureSettings; + } + + [HttpGet] + [Route(HttpRouteConstants.CALLS + "/")] + public IActionResult OnGetCalls() + { + _logger.Info("Getting calls"); + _eventPublisher.Publish("GetCalls", "Getting calls"); + + List> calls = []; + foreach (var callHandler in _botService.CallHandlers.Values) + { + var call = callHandler.Call; + var callPath = "/" + HttpRouteConstants.CALL_ROUTE.Replace("{callLegId}", call.Id); + var callUri = new Uri(_settings.CallControlBaseUrl, callPath).AbsoluteUri; + var values = new Dictionary + { + { "legId", call.Id }, + { "scenarioId", call.ScenarioId.ToString() }, + { "call", callUri }, + { "logs", callUri.Replace("/calls/", "/logs/") }, + }; + calls.Add(values); + } + + return Ok(calls); + } + + [HttpDelete] + [Route(HttpRouteConstants.CALL_ROUTE)] + public async Task OnEndCallAsync(string callLegId) + { + var message = $"Ending call {callLegId}"; + _logger.Info(message); + _eventPublisher.Publish("EndingCall", message); + + try + { + await _botService.EndCallByCallLegIdAsync(callLegId).ConfigureAwait(false); + return Ok(); + } + catch (Exception e) + { + return StatusCode(500, e.ToString()); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/JoinCallController.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/JoinCallController.cs new file mode 100644 index 000000000..a4c884b5d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/JoinCallController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Graph.Communications.Core.Exceptions; +using Microsoft.Kiota.Abstractions.Extensions; +using RecordingBot.Model.Constants; +using RecordingBot.Model.Extension; +using RecordingBot.Model.Models; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Http.Controllers +{ + /// + /// JoinCallController is a third-party controller (non-Bot Framework) that can be called in CVI scenario to trigger the bot to join a call. + /// + [ApiController] + public class JoinCallController : ControllerBase + { + private readonly IGraphLogger _logger; + private readonly IBotService _botService; + private readonly AzureSettings _settings; + private readonly IEventPublisher _eventPublisher; + + public JoinCallController(IGraphLogger logger, IEventPublisher eventPublisher, IBotService botService, AzureSettings azureSettings) + { + _logger = logger; + _botService = botService; + _settings = azureSettings; + _eventPublisher = eventPublisher; + } + + [HttpPost] + [Route(HttpRouteConstants.JOIN_CALLS)] + public async Task JoinCallAsync([FromBody] JoinCallBody joinCallBody) + { + try + { + var call = await _botService.JoinCallAsync(joinCallBody).ConfigureAwait(false); + var callPath = $"/{HttpRouteConstants.CALL_ROUTE.Replace("{callLegId}", call.Id)}"; + var callUri = $"{_settings.ServiceCname}{callPath}"; + + _eventPublisher.Publish("JoinCall", $"Call.id = {call.Id}"); + + return Ok(new JoinURLResponse + { + Call = callUri, + CallId = call.Id, + ScenarioId = call.ScenarioId + }); + } + catch (ServiceException e) + { + var problemDetails = new ProblemDetails { Detail = e.ToString(), Status = (int)e.StatusCode }; + problemDetails.Extensions.AddOrReplace("responseHeaders", e.ResponseHeaders); + + return StatusCode(500, problemDetails); + } + catch (Exception e) + { + _logger.Error(e, $"Received HTTP {Request.Method}, {Request.GetUrl()}"); + + return StatusCode(500, e.ToString()); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/PlatformCallController.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/PlatformCallController.cs new file mode 100644 index 000000000..7e3f43774 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Http/Controllers/PlatformCallController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph.Communications.Client; +using Microsoft.Graph.Communications.Client.Authentication; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using RecordingBot.Model.Constants; +using RecordingBot.Model.Extension; +using RecordingBot.Services.Contract; +using System; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Http.Controllers +{ + [ApiController] + [Route(HttpRouteConstants.CALL_SIGNALING_ROUTE_PREFIX)] + public class PlatformCallController : ControllerBase + { + private readonly IGraphLogger _logger; + private readonly ICommunicationsClient _commsClient; + + public PlatformCallController(IGraphLogger logger, IBotService botService) + { + _logger = logger; + _commsClient = botService.Client; + } + + [HttpPost] + [Route(HttpRouteConstants.ON_NOTIFICATION_REQUEST_ROUTE)] + [Route(HttpRouteConstants.ON_INCOMING_REQUEST_ROUTE)] + public async Task OnNotificationRequestAsync( + [FromHeader(Name = "Client-Request-Id")] Guid? clientRequestId, + [FromHeader(Name = "X-Microsoft-Skype-Message-ID")] Guid? skypeRequestId, + [FromHeader(Name = "Scenario-Id")] Guid? clientScenarioId, + [FromHeader(Name = "X-Microsoft-Skype-Chain-ID")] Guid? skypeScenarioId, + [FromBody] CommsNotifications notifications) + { + _logger.Info($"Received HTTP {Request.Method}, {Request.GetUrl()}"); + + Guid requestId = clientRequestId ?? skypeRequestId ?? default; + Guid scenarioId = clientScenarioId ?? skypeScenarioId ?? default; + + // Convert Request Authorization Request Header + if (Request.Headers.Authorization.Count != 1) + { + return Unauthorized(); + } + + var schemeAndParameter = Request.Headers.Authorization[0].Split(" "); + if (schemeAndParameter.Length != 2) + { + return Unauthorized(); + } + + RequestValidationResult result; + + var httpRequestMessage = new System.Net.Http.HttpRequestMessage(); + httpRequestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(schemeAndParameter[0],schemeAndParameter[1]); + + // Autenticate the incoming request. + result = await _commsClient.AuthenticationProvider + .ValidateInboundRequestAsync(httpRequestMessage) + .ConfigureAwait(false); + + if (result.IsValid) + { + // Pass the incoming notification to the sdk. The sdk takes care of what to do with it. + return Accepted(_commsClient.ProcessNotifications(Request.GetUri(), notifications, result.TenantId, requestId, scenarioId)); + } + + return Unauthorized(); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/AudioProcessor.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/AudioProcessor.cs new file mode 100644 index 000000000..f53f6e444 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/AudioProcessor.cs @@ -0,0 +1,142 @@ +using NAudio.Wave; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using RecordingBot.Services.Util; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Media +{ + public class AudioProcessor : BufferBase + { + readonly Dictionary _writers = []; + private readonly string _processorId = null; + private readonly AzureSettings _settings; + + public AudioProcessor(IAzureSettings settings) + { + _processorId = Guid.NewGuid().ToString(); + _settings = (AzureSettings)settings; + } + + protected override async Task Process(SerializableAudioMediaBuffer data) + { + if (data.Timestamp == 0) + { + return; + } + + var path = Path.Combine(Path.GetTempPath(), BotConstants.DEFAULT_OUTPUT_FOLDER, _settings.MediaFolder, _processorId); + + // First, write all audio buffer, unless the data.IsSilence is checked for true, into the all speakers buffer + var all = "all"; + var all_writer = _writers.TryGetValue(all, out WaveFileWriter allWaveWriter) ? allWaveWriter : InitialiseWavFileWriter(path, all); + + if (data.Buffer != null) + { + // Buffers are saved to disk even when there is silence. + // If you do not want this to happen, check if data.IsSilence == true. + await all_writer.WriteAsync(data.Buffer.AsMemory(0, data.Buffer.Length)).ConfigureAwait(false); + } + + if (data.SerializableUnmixedAudioBuffers != null) + { + foreach (var s in data.SerializableUnmixedAudioBuffers) + { + if (string.IsNullOrWhiteSpace(s.AdId) || string.IsNullOrWhiteSpace(s.DisplayName)) + { + continue; + } + + var id = s.AdId; + + var writer = _writers.TryGetValue(id, out WaveFileWriter bufferWaveWriter) ? bufferWaveWriter : InitialiseWavFileWriter(path, id); + + // Write audio buffer into the WAV file for individual speaker + await writer.WriteAsync(s.Buffer.AsMemory(0, s.Buffer.Length)).ConfigureAwait(false); + + // Write audio buffer into the WAV file for all speakers + await all_writer.WriteAsync(s.Buffer.AsMemory(0, s.Buffer.Length)).ConfigureAwait(false); + } + } + } + + private WaveFileWriter InitialiseWavFileWriter(string rootFolder, string id) + { + var path = AudioFileUtils.CreateFilePath(rootFolder, $"{id}.wav"); + + // Initialize the Wave Format using the default PCM 16bit 16K supported by Teams audio settings + var writer = new WaveFileWriter(path, new WaveFormat( + rate: AudioConstants.DEFAULT_SAMPLE_RATE, + bits: AudioConstants.DEFAULT_BITS, + channels: AudioConstants.DEFAULT_CHANNELS)); + + _writers.Add(id, writer); + + return writer; + } + + public async Task Finalize() + { + //drain the un-processed buffers on this object + while (Buffer.Count > 0) + { + await Task.Delay(200); + } + + var archiveFile = Path.Combine(Path.GetTempPath(), BotConstants.DEFAULT_OUTPUT_FOLDER, _settings.MediaFolder, _processorId, $"{Guid.NewGuid()}.zip"); + + try + { + using var stream = File.OpenWrite(archiveFile); + using ZipArchive archive = new(stream, ZipArchiveMode.Create); + // drain all the writers + foreach (var writer in _writers.Values) + { + List localFiles = []; + var localArchive = archive; //protect the closure below + var localFileName = writer.Filename; + + localFiles.Add(writer.Filename); + + await writer.FlushAsync(); + + writer.Dispose(); + + // Is Resampling and/or mono to stereo conversion required? + if (_settings.AudioSettings.WavSettings != null) + { + // The resampling is required + localFiles.Add(AudioFileUtils.ResampleAudio(localFileName, _settings.AudioSettings.WavSettings, _settings.IsStereo)); + } + else if (_settings.IsStereo) // Is Stereo audio required? + { + // Convert mono WAV to stereo + localFiles.Add(AudioFileUtils.ConvertToStereo(localFileName)); + } + + // Remove temporary saved local WAV file from the disk + foreach (var localFile in localFiles) + { + await Task.Run(() => + { + var fInfo = new FileInfo(localFile); + localArchive.CreateEntryFromFile(localFile, fInfo.Name, CompressionLevel.Optimal); + File.Delete(localFile); + }).ConfigureAwait(false); + } + } + } + finally + { + await End(); + } + + return archiveFile; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/MediaStream.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/MediaStream.cs new file mode 100644 index 000000000..6b3cce6f9 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/MediaStream.cs @@ -0,0 +1,183 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Skype.Bots.Media; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using RecordingBot.Services.Util; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; + +namespace RecordingBot.Services.Media +{ + public class MediaStream : IMediaStream + { + private readonly AzureSettings _settings; + private readonly IGraphLogger _logger; + private readonly string _mediaId; + + private readonly BufferBlock _buffer; + private readonly CancellationTokenSource _tokenSource; + + private readonly AudioProcessor _currentAudioProcessor; + private CaptureEvents _capture; + + private readonly SemaphoreSlim _syncLock = new(1); + + private bool _isRunning = false; + protected bool _isDraining; + + public MediaStream(IAzureSettings settings, IGraphLogger logger, string mediaId) + { + _settings = (AzureSettings)settings; + _logger = logger; + _mediaId = mediaId; + + _tokenSource = new CancellationTokenSource(); + + _buffer = new BufferBlock(new DataflowBlockOptions { CancellationToken = _tokenSource.Token }); + _currentAudioProcessor = new AudioProcessor(_settings); + + if (_settings.CaptureEvents) + { + _capture = new CaptureEvents(Path.Combine(Path.GetTempPath(), BotConstants.DEFAULT_OUTPUT_FOLDER, _settings.EventsFolder, _mediaId, "media")); + } + } + + public async Task AppendAudioBuffer(AudioMediaBuffer buffer, List participants) + { + if (!_isRunning) + { + await Start().ConfigureAwait(false); + } + + try + { + await _buffer.SendAsync(new SerializableAudioMediaBuffer(buffer, participants), _tokenSource.Token).ConfigureAwait(false); + } + catch (TaskCanceledException e) + { + _buffer?.Complete(); + _logger.Error(e, "Cannot enqueue because queuing operation has been cancelled"); + } + } + + private async Task Start() + { + await _syncLock.WaitAsync().ConfigureAwait(false); + + if (!_isRunning) + { + await Task.Run(Process).ConfigureAwait(false); + + _isRunning = true; + } + + _syncLock.Release(); + } + + private async Task Process() + { + if (_settings.CaptureEvents && !_isDraining && _capture == null) + { + _capture = new CaptureEvents(Path.Combine(Path.GetTempPath(), BotConstants.DEFAULT_OUTPUT_FOLDER, _settings.EventsFolder, _mediaId, "media")); + } + + try + { + while (await _buffer.OutputAvailableAsync(_tokenSource.Token).ConfigureAwait(false)) + { + SerializableAudioMediaBuffer data = await _buffer.ReceiveAsync(_tokenSource.Token).ConfigureAwait(false); + + if (_settings.CaptureEvents) + { + await _capture?.Append(data); + } + + await _currentAudioProcessor.Append(data); + + _tokenSource.Token.ThrowIfCancellationRequested(); + } + } + catch (TaskCanceledException ex) + { + _logger.Error(ex, "The queue processing task has been cancelled."); + } + catch (ObjectDisposedException ex) + { + _logger.Error(ex, "The queue processing task object has been disposed."); + } + catch (Exception ex) + { + // Catch all other exceptions and log + _logger.Error(ex, "Caught Exception"); + + // Continue processing elements in the queue + await Process().ConfigureAwait(false); + } + + //send final segment as a last precation in case the loop did not process it + if (_currentAudioProcessor != null) + { + await ChunkProcess().ConfigureAwait(false); + } + + if (_settings.CaptureEvents) + { + await _capture.Finalize().ConfigureAwait(false); + } + + _isDraining = false; + } + + public async Task End() + { + if (!_isRunning) + { + return; + } + + await _syncLock.WaitAsync().ConfigureAwait(false); + + if (_isRunning) + { + _isDraining = true; + while (_buffer.Count > 0) + { + await Task.Delay(200).ConfigureAwait(false); + } + + _buffer.Complete(); + _buffer.TryDispose(); + _tokenSource.Cancel(); + _tokenSource.Dispose(); + _isRunning = false; + + while (_isDraining) + { + await Task.Delay(200).ConfigureAwait(false); + } + } + + _syncLock.Release(); + } + + async Task ChunkProcess() + { + try + { + var finalData = await _currentAudioProcessor.Finalize().ConfigureAwait(false); + _logger.Info($"Recording saved to: {finalData}"); + } + catch (Exception ex) + { + _logger.Error(ex, "Caught exception while processing chunck."); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableAudioMediaBuffer.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableAudioMediaBuffer.cs new file mode 100644 index 000000000..982edec32 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableAudioMediaBuffer.cs @@ -0,0 +1,123 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Models; +using Microsoft.Skype.Bots.Media; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace RecordingBot.Services.Media +{ + public class SerializableAudioMediaBuffer : IDisposable + { + private readonly List _participants; + + public uint[] ActiveSpeakers { get; set; } + public long Length { get; set; } + public bool IsSilence { get; set; } + public long Timestamp { get; set; } + public byte[] Buffer { get; set; } + public SerializableUnmixedAudioBuffer[] SerializableUnmixedAudioBuffers { get; set; } + + public SerializableAudioMediaBuffer() + { } + + public SerializableAudioMediaBuffer(AudioMediaBuffer buffer, List participants) + { + _participants = participants; + + Length = buffer.Length; + ActiveSpeakers = buffer.ActiveSpeakers; + IsSilence = buffer.IsSilence; + Timestamp = buffer.Timestamp; + + if (Length > 0) + { + Buffer = new byte[Length]; + Marshal.Copy(buffer.Data, Buffer, 0, (int)Length); + } + + if (buffer.UnmixedAudioBuffers != null) + { + SerializableUnmixedAudioBuffers = buffer.UnmixedAudioBuffers + .Where(unmixedBuffer => unmixedBuffer.Length > 0) + .Select(unmixedBuffer => new SerializableUnmixedAudioBuffer(unmixedBuffer, GetParticipantFromMSI(unmixedBuffer.ActiveSpeakerId))) + .ToArray(); + } + } + + private IParticipant GetParticipantFromMSI(uint msi) + { + return _participants.SingleOrDefault( + participant => participant.Resource.IsInLobby == false + && participant.Resource.MediaStreams + .Any(mediaStreamy => mediaStreamy.SourceId == msi.ToString())); + } + + public void Dispose() + { + SerializableUnmixedAudioBuffers = null; + Buffer = null; + + GC.SuppressFinalize(this); + } + + public class SerializableUnmixedAudioBuffer + { + public uint ActiveSpeakerId { get; set; } + public long Length { get; set; } + public long OriginalSenderTimestamp { get; set; } + public string DisplayName { get; set; } + public string AdId { get; set; } + public IDictionary AdditionalData { get; set; } + public byte[] Buffer { get; set; } + + public SerializableUnmixedAudioBuffer() + { } + + public SerializableUnmixedAudioBuffer(UnmixedAudioBuffer buffer, IParticipant participant) + { + ActiveSpeakerId = buffer.ActiveSpeakerId; + Length = buffer.Length; + OriginalSenderTimestamp = buffer.OriginalSenderTimestamp; + + var identity = AddParticipant(participant); + + if (identity != null) + { + DisplayName = identity.DisplayName; + AdId = identity.Id; + } + else + { + var user = participant?.Resource?.Info?.Identity?.User; + if (user != null) + { + DisplayName = user.DisplayName; + AdId = user.Id; + AdditionalData = user.AdditionalData; + } + } + + Buffer = new byte[Length]; + Marshal.Copy(buffer.Data, Buffer, 0, (int)Length); + } + + private static Identity AddParticipant(IParticipant participant) + { + if (participant?.Resource?.Info?.Identity?.AdditionalData != null) + { + foreach (var identity in participant.Resource.Info.Identity.AdditionalData) + { + if (identity.Key != "applicationInstance" && identity.Value is Identity) + { + return identity.Value as Identity; + } + } + } + + return null; + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableQualityOfExperienceData.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableQualityOfExperienceData.cs new file mode 100644 index 000000000..3013086db --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Media/SerializableQualityOfExperienceData.cs @@ -0,0 +1,20 @@ +using Microsoft.Skype.Bots.Media; + +namespace RecordingBot.Services.Media +{ + public class SerializableAudioQualityOfExperienceData + { + public string Id; + public long AverageInBoundNetworkJitter; + public long MaximumInBoundNetworkJitter; + public long TotalMediaDuration; + + public SerializableAudioQualityOfExperienceData(string id, AudioQualityOfExperienceData aQoE) + { + Id = id; + AverageInBoundNetworkJitter = aQoE.AudioMetrics.AverageInboundNetworkJitter.Ticks; + MaximumInBoundNetworkJitter = aQoE.AudioMetrics.MaximumInboundNetworkJitter.Ticks; + TotalMediaDuration = aQoE.TotalMediaDuration.Ticks; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Properties/AssemblyInfo.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..25a20bbdb --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("RecordingBot.Services")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("RecordingBot.Services")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("55c6d645-f418-4262-beb2-9bae523dec60")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/RecordingBot.Services.csproj b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/RecordingBot.Services.csproj new file mode 100644 index 000000000..adc5c3901 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/RecordingBot.Services.csproj @@ -0,0 +1,32 @@ + + + Library + net8.0 + false + x64 + latest + disable + + + + true + true + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AppHost.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AppHost.cs new file mode 100644 index 000000000..bbe3f675e --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AppHost.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Graph.Communications.Common.OData; +using Microsoft.Graph.Communications.Common.Telemetry; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using RecordingBot.Services.Http.Controllers; +using System; +using System.Net; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RecordingBot.Services.ServiceSetup +{ + public class AppHost + { + private IGraphLogger _logger; + + private IServiceProvider ServiceProvider { get; set; } + public static AppHost AppHostInstance { get; private set; } + + public AppHost() + { + AppHostInstance = this; + } + + public void Boot(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; + + DotNetEnv.Env.Load(path: null, options: new DotNetEnv.LoadOptions()); + builder.Configuration.AddEnvironmentVariables(); + + // Load Azure Settings + var azureSettings = new AzureSettings(); + builder.Configuration.GetSection(nameof(AzureSettings)).Bind(azureSettings); + azureSettings.Initialize(); + builder.Services.AddSingleton(azureSettings); + + // Setup Listening Urls + builder.WebHost.UseKestrel(serverOptions => + { + serverOptions.ListenAnyIP(azureSettings.CallSignalingPort + 1); + serverOptions.ListenAnyIP(azureSettings.CallSignalingPort, config => config.UseHttps(azureSettings.Certificate)); + }); + + // Add services to the container. + builder.Services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.WriteIndented = true; //pretty + options.JsonSerializerOptions.AllowTrailingCommas = true; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; + options.JsonSerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + options.JsonSerializerOptions.Converters.Add(new ODataJsonConverterFactory(null, null, typeAssemblies: SerializerAssemblies.Assemblies)); + }).AddApplicationPart(typeof(PlatformCallController).Assembly); + + builder.Services.AddCoreServices(builder.Configuration); + + var app = builder.Build(); + + ServiceProvider = app.Services; + + _logger = Resolve(); + + try + { + Resolve(); + Resolve().Initialize(); + } + catch (Exception e) + { + app.Logger.LogError(e, "Unhandled exception in Boot()"); + return; + } + + // Configure the HTTP request pipeline. + app.UsePathBase(azureSettings.PodPathBase); + app.UsePathBase(azureSettings.ServicePath); + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.UseRouting(); + + app.MapControllers(); + + app.Run(); + } + + public T Resolve() + { + return ServiceProvider.GetService(); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AudioSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AudioSettings.cs new file mode 100644 index 000000000..685c8109a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AudioSettings.cs @@ -0,0 +1,7 @@ +namespace RecordingBot.Services.ServiceSetup +{ + public class AudioSettings + { + public WAVSettings WavSettings { get; set; } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs new file mode 100644 index 000000000..77c49f6b0 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Skype.Bots.Media; +using RecordingBot.Model.Constants; +using RecordingBot.Services.Contract; +using System; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +namespace RecordingBot.Services.ServiceSetup +{ + public partial class AzureSettings : IAzureSettings + { + public string ServiceDnsName { get; set; } + public string ServicePath {get;set;} = "/"; + public string ServiceCname { get; set; } + public string CertificateThumbprint { get; set; } + public Uri CallControlBaseUrl { get; set; } + public Uri PlaceCallEndpointUrl { get; set; } + public MediaPlatformSettings MediaPlatformSettings { get; private set; } + public string AadAppId { get; set; } + public string AadAppSecret { get; set; } + public int InstancePublicPort { get; set; } + public int InstanceInternalPort { get; set; } + public int CallSignalingPort { get; set; } + public int CallSignalingPublicPort {get;set;} = 443; + public bool CaptureEvents { get; set; } = false; + public string PodName { get; set; } + public string MediaFolder { get; set; } + public string EventsFolder { get; set; } + public string TopicName { get; set; } = "recordingbotevents"; + public string RegionName { get; set; } = "australiaeast"; + public string TopicKey { get; set; } + public AudioSettings AudioSettings { get; set; } + public bool IsStereo { get; set; } + public int WAVSampleRate { get; set; } + public int WAVQuality { get; set; } + public PathString PodPathBase { get; private set; } + public X509Certificate2 Certificate { get; private set; } + + public void Initialize() + { + if (string.IsNullOrWhiteSpace(ServiceCname)) + { + ServiceCname = ServiceDnsName; + } + + Certificate = GetCertificateFromStore(); + + int podNumber = 0; + + if (!string.IsNullOrEmpty(PodName)) + { + _ = int.TryParse(PodNumberRegex().Match(PodName).Value, out podNumber); + } + + // Create structured config objects for service. + CallControlBaseUrl = new Uri($"https://{ServiceCname}{(CallSignalingPublicPort != 443 ? ":" + CallSignalingPublicPort : "")}{ServicePath}{podNumber}/{HttpRouteConstants.CALL_SIGNALING_ROUTE_PREFIX}/{HttpRouteConstants.ON_NOTIFICATION_REQUEST_ROUTE}"); + PodPathBase = $"{ServicePath}{podNumber}"; + + MediaPlatformSettings = new MediaPlatformSettings + { + MediaPlatformInstanceSettings = new MediaPlatformInstanceSettings + { + Certificate = Certificate, + InstanceInternalPort = InstanceInternalPort, + InstancePublicIPAddress = IPAddress.Any, + InstancePublicPort = InstancePublicPort + podNumber, + ServiceFqdn = ServiceCname + }, + ApplicationId = AadAppId, + }; + + // Initialize Audio Settings + AudioSettings = new AudioSettings + { + WavSettings = (WAVSampleRate > 0) ? new WAVSettings(WAVSampleRate, WAVQuality) : null + }; + } + + /// + /// Helper to search the certificate store by its thumbprint. + /// + /// Certificate if found. + /// No certificate with thumbprint {CertificateThumbprint} was found in the machine store. + private X509Certificate2 GetCertificateFromStore() + { + using (X509Store store = new(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadOnly); + var certs = store.Certificates.Find(X509FindType.FindByThumbprint, CertificateThumbprint, validOnly: false); + + if (certs.Count != 1) + { + throw new Exception($"No certificate with thumbprint {CertificateThumbprint} was found in the machine store."); + } + + return certs[0]; + } + } + + [GeneratedRegex(@"\d+$")] + private static partial Regex PodNumberRegex(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServiceHost.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServiceHost.cs new file mode 100644 index 000000000..f313580da --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServiceHost.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Graph.Communications.Common.Telemetry; +using RecordingBot.Services.Bot; +using RecordingBot.Services.Contract; +using RecordingBot.Services.Util; +using System; + +namespace RecordingBot.Services.ServiceSetup +{ + public class ServiceHost : IServiceHost + { + public IServiceCollection Services { get; private set; } + public IServiceProvider ServiceProvider { get; private set; } + + public ServiceHost Configure(IServiceCollection services, IConfiguration configuration) + { + Services = services; + Services.AddSingleton(sp => + { + var logger = new GraphLogger("RecordingBot", redirectToTrace: false); + logger.BindToILoggerFactory(sp.GetRequiredService()); + return logger; + }); + Services.AddSingleton(_ => _.GetRequiredService()); + Services.AddSingleton(_ => new EventGridPublisher(_.GetRequiredService>().Value)); + Services.AddSingleton(); + + return this; + } + + public IServiceProvider Build() + { + ServiceProvider = Services.BuildServiceProvider(); + return ServiceProvider; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServicesExtension.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServicesExtension.cs new file mode 100644 index 000000000..72ca7d59c --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/ServicesExtension.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RecordingBot.Services.Contract; +using System; + +namespace RecordingBot.Services.ServiceSetup +{ + public static class ServicesExtension + { + public static ServiceHost AddCoreServices(this IServiceCollection services, IConfiguration configuration) + { + return new ServiceHost().Configure(services, configuration); + } + + public static TConfig ConfigureConfigObject(this IServiceCollection services, IConfiguration configuration) + where TConfig : class, new() + { + ArgumentNullException.ThrowIfNull(services, nameof(services)); + ArgumentNullException.ThrowIfNull(configuration, nameof(configuration)); + + var config = new TConfig(); + configuration.Bind(config); + + if (config is IInitializable init) + { + init.Initialize(); + } + + services.AddSingleton(config); + return config; + } + + public static TConfig ConfigureConfigObject(this IServiceCollection services) + where TConfig : class, new() + { + ArgumentNullException.ThrowIfNull(services, nameof(services)); + + var config = new TConfig(); + + if (config is IInitializable init) + { + init.Initialize(); + } + + services.AddSingleton(config); + return config; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/WavSettings.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/WavSettings.cs new file mode 100644 index 000000000..d69ecd862 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/ServiceSetup/WavSettings.cs @@ -0,0 +1,14 @@ +namespace RecordingBot.Services.ServiceSetup +{ + public class WAVSettings + { + public int? SampleRate { get; set; } + public int? Quality { get; set; } + + public WAVSettings(int sampleRate, int quality) + { + SampleRate = sampleRate; + Quality = quality; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/AudioFileUtils.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/AudioFileUtils.cs new file mode 100644 index 000000000..243151d4f --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/AudioFileUtils.cs @@ -0,0 +1,57 @@ +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using RecordingBot.Model.Constants; +using RecordingBot.Services.ServiceSetup; +using System.IO; + +namespace RecordingBot.Services.Util +{ + public static class AudioFileUtils + { + private const string STEREO = "stereo"; + + public static string CreateFilePath(string rootFolder, string fileName) + { + var path = Path.Combine(rootFolder, fileName); + var fInfo = new FileInfo(path); + if (fInfo.Directory != null && !fInfo.Directory.Exists) + { + fInfo.Directory.Create(); + } + + return path; + } + + public static string ConvertToStereo(string monoFilePath) + { + var outputFilePath = monoFilePath[..^4] + monoFilePath[^4..].Replace(".wav", $"-{STEREO}.wav"); + using (var inputReader = new AudioFileReader(monoFilePath)) + { + // convert our mono ISampleProvider to stereo + var stereo = new MonoToStereoSampleProvider(inputReader); + + // write the stereo audio out to a WAV file + WaveFileWriter.CreateWaveFile16(outputFilePath, stereo); + } + + return outputFilePath; + } + + public static string ResampleAudio(string audioFilePath, WAVSettings resamplerSettings, bool convertToStereo) + { + var stereoFlag = (convertToStereo)? $"-{STEREO}" : ""; + var outFile = audioFilePath[..^4] + audioFilePath[^4..].Replace(".wav", $"-{resamplerSettings.SampleRate / 1000}kHz{stereoFlag}.wav"); + using (var reader = new WaveFileReader(audioFilePath)) + { + var outFormat = new WaveFormat((int)resamplerSettings.SampleRate, (convertToStereo)? 2 : 1); + + using var resampler = new MediaFoundationResampler(reader, outFormat); + resampler.ResamplerQuality = resamplerSettings.Quality * AudioConstants.HIGHEST_SAMPLING_QUALITY_LEVEL / 100 + ?? AudioConstants.HIGHEST_SAMPLING_QUALITY_LEVEL; + WaveFileWriter.CreateWaveFile(outFile, resampler); + } + + return outFile; + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/BufferBase.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/BufferBase.cs new file mode 100644 index 000000000..d569052a5 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/BufferBase.cs @@ -0,0 +1,114 @@ +using Microsoft.Identity.Client; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; + +namespace RecordingBot.Services.Util +{ + public abstract class BufferBase + { + protected BufferBlock Buffer; + protected CancellationTokenSource TokenSource; + protected bool IsRunning = false; + private readonly SemaphoreSlim _syncLock = new(1); + + protected BufferBase() + { + Buffer = new BufferBlock(); + TokenSource = new CancellationTokenSource(); + } + + protected BufferBase(CancellationTokenSource token) + { + Buffer = new BufferBlock(); + TokenSource = token; + } + + public async Task Append(T obj) + { + if (!IsRunning) + { + await Start(); + } + + try + { + await Buffer.SendAsync(obj, TokenSource.Token).ConfigureAwait(false); + } + catch (TaskCanceledException e) + { + Buffer?.Complete(); + + Debug.Write($"Cannot enqueue because queuing operation has been cancelled. Exception: {e}"); + } + } + + private async Task Start() + { + await _syncLock.WaitAsync().ConfigureAwait(false); + + if (!IsRunning) + { + TokenSource ??= new CancellationTokenSource(); + + Buffer = new BufferBlock(new DataflowBlockOptions { CancellationToken = TokenSource.Token }); + await Task.Factory.StartNew(Process).ConfigureAwait(false); + IsRunning = true; + } + + _syncLock.Release(); + } + + private async Task Process() + { + try + { + while (await Buffer.OutputAvailableAsync(TokenSource.Token).ConfigureAwait(false)) + { + T data = await Buffer.ReceiveAsync(TokenSource.Token).ConfigureAwait(false); + + await Task.Run(() => Process(data)).ConfigureAwait(false); + + TokenSource.Token.ThrowIfCancellationRequested(); + } + } + catch (TaskCanceledException ex) + { + Debug.Write($"The queue processing task has been cancelled. Exception: {ex}"); + } + catch (ObjectDisposedException ex) + { + Debug.Write($"The queue processing task object has been disposed. Exception: {ex}"); + } + catch (Exception ex) + { + // Catch all other exceptions and log + Debug.Write($"Caught Exception: {ex}"); + + // Continue processing elements in the queue + await Process().ConfigureAwait(false); + } + } + + public virtual async Task End() + { + if (IsRunning) + { + await _syncLock.WaitAsync().ConfigureAwait(false); + + if (IsRunning) + { + Buffer.Complete(); + TokenSource = null; + IsRunning = false; + } + + _syncLock.Release(); + } + } + + protected abstract Task Process(T data); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/CaptureEvents.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/CaptureEvents.cs new file mode 100644 index 000000000..ef6a5644a --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/CaptureEvents.cs @@ -0,0 +1,121 @@ +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Resources; +using Newtonsoft.Json; +using Newtonsoft.Json.Bson; +using RecordingBot.Model.Models; +using RecordingBot.Services.Media; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RecordingBot.Services.Util +{ + public class CaptureEvents : BufferBase + { + private readonly string _path; + private readonly JsonSerializer _serializer; + + public CaptureEvents(string path) + { + _path = path; + _serializer = new JsonSerializer(); + } + + private async Task SaveJsonFile(Object data, string fileName) + { + Directory.CreateDirectory(_path); + + var fullName = Path.Combine(_path, fileName); + + using (var stream = File.CreateText(fullName)) + { + using (var writer = new JsonTextWriter(stream)) + { + writer.Formatting = Formatting.Indented; + _serializer.Serialize(writer, data); + await writer.FlushAsync(); + } + } + } + + private async Task SaveBsonFile(object data, string fileName) + { + Directory.CreateDirectory(_path); + + var fullName = Path.Combine(_path, fileName); + + using (var file = File.Create(fullName)) + { + using (var bson = new BsonDataWriter(file)) + { + _serializer.Serialize(bson, data); + await bson.FlushAsync(); + } + } + } + + private async Task SaveQualityOfExperienceData(SerializableAudioQualityOfExperienceData data) + { + await SaveJsonFile(data, $"{data.Id}-AudioQoE.json"); + } + + private async Task SaveAudioMediaBuffer(SerializableAudioMediaBuffer data) + { + await SaveBsonFile(data, data.Timestamp.ToString()); + } + + private async Task SaveParticipantEvent(CollectionEventArgs data) + { + var participant = new SerializableParticipantEvent + { + AddedResources = new List(data.AddedResources.Select(addedResource => new SerilizableParticipant(addedResource))), + RemovedResources = new List(data.RemovedResources.Select(removedResource => new SerilizableParticipant(removedResource))) + }; + + await SaveJsonFile(participant, $"{DateTime.UtcNow.Ticks}-participant.json"); + } + + private async Task SaveRequests(string data) + { + Directory.CreateDirectory(_path); + + var fullName = Path.Combine(_path, $"{DateTime.UtcNow.Ticks}.json"); + await File.AppendAllTextAsync(fullName, data, Encoding.Unicode); + } + + protected override async Task Process(object data) + { + switch (data) + { + case string d: + await SaveRequests(d); + return; + case CollectionEventArgs d: + await SaveParticipantEvent(d); + return; + case SerializableAudioMediaBuffer d: + await SaveAudioMediaBuffer(d); + return; + case SerializableAudioQualityOfExperienceData q: + await SaveQualityOfExperienceData(q); + return; + default: + return; + } + } + + public async Task Finalize() + { + // drain the un-processed buffers on this object + while (Buffer.Count > 0) + { + await Task.Delay(200); + } + + await End(); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/EventGridPublisher.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/EventGridPublisher.cs new file mode 100644 index 000000000..f13b55503 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/EventGridPublisher.cs @@ -0,0 +1,57 @@ +using Azure; +using Azure.Messaging.EventGrid; +using RecordingBot.Model.Constants; +using RecordingBot.Model.Models; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using System; +using System.Collections.Generic; + +namespace RecordingBot.Services.Util +{ + public class EventGridPublisher : IEventPublisher + { + private readonly string _topicName; + private readonly string _regionName; + private readonly string _topicKey; + + public EventGridPublisher(AzureSettings settings) + { + _topicName = settings.TopicName ?? "recordingbotevents"; + _topicKey = settings.TopicKey; + _regionName = settings.RegionName; + } + + public void Publish(string subject, string message, string topicName) + { + if (!string.IsNullOrWhiteSpace(_topicKey)) + { + if (string.IsNullOrWhiteSpace(topicName)) + { + topicName = _topicName; + } + + var topicEndpoint = string.Format(BotConstants.TOPIC_ENDPOINT, topicName, _regionName); + + var client = new EventGridPublisherClient(new Uri(topicEndpoint), new AzureKeyCredential(_topicKey)); + var eventGrid = new EventGridEvent(subject, "RecordingBot.BotEventData", "2.0", new BotEventData { Message = message }) + { + EventTime = DateTime.Now + }; + client.SendEvent(eventGrid); + if (subject.StartsWith("CallTerminated")) + { + Console.WriteLine($"Publish to {topicName} subject {subject} message {message}"); + } + else + { + Console.WriteLine($"Publish to {topicName} subject {subject}"); + } + } + else + { + Console.WriteLine($"Skipped publishing {subject} events to Event Grid topic {topicName} - No topic key specified"); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/JoinInfo.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/JoinInfo.cs new file mode 100644 index 000000000..5b2edf29d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Services/Util/JoinInfo.cs @@ -0,0 +1,64 @@ +using Microsoft.Graph.Contracts; +using Microsoft.Graph.Models; +using RecordingBot.Model.Models; +using System; +using System.IO; +using System.Net; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Text.RegularExpressions; + +namespace RecordingBot.Services.Util +{ + public static partial class JoinInfo + { + public static (ChatInfo, MeetingInfo) ParseJoinURL(string joinURL) + { + ArgumentException.ThrowIfNullOrWhiteSpace(joinURL, nameof(joinURL)); + + var decodedURL = WebUtility.UrlDecode(joinURL); + + //// URL being needs to be in this format. + //// https://teams.microsoft.com/l/meetup-join/19:cd9ce3da56624fe69c9d7cd026f9126d@thread.skype/1509579179399?context={"Tid":"72f988bf-86f1-41af-91ab-2d7cd011db47","Oid":"550fae72-d251-43ec-868c-373732c2704f","MessageId":"1536978844957"} + + var match = UrlFormat().Match(decodedURL); + if (!match.Success) + { + throw new ArgumentException($"Join URL cannot be parsed: {joinURL}", nameof(joinURL)); + } + + Meeting meeting; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(match.Groups["context"].Value))) + { + meeting = (Meeting)new DataContractJsonSerializer(typeof(Meeting)).ReadObject(stream); + } + + if (string.IsNullOrEmpty(meeting.Tid)) + { + throw new ArgumentException("Join URL is invalid: missing Tid", nameof(joinURL)); + } + + var chatInfo = new ChatInfo + { + ThreadId = match.Groups["thread"].Value, + MessageId = match.Groups["message"].Value, + ReplyChainMessageId = meeting.MessageId, + }; + + var meetingInfo = new OrganizerMeetingInfo + { + Organizer = new IdentitySet + { + User = new Identity { Id = meeting.Oid }, + }, + }; + meetingInfo.Organizer.User.SetTenantId(meeting.Tid); + + return (chatInfo, meetingInfo); + } + + [GeneratedRegex("https://teams\\.microsoft\\.com.*/(?[^/]+)/(?[^/]+)\\?context=(?{.*})")] + private static partial Regex UrlFormat(); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/.env-template b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/.env-template new file mode 100644 index 000000000..19fc92646 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/.env-template @@ -0,0 +1,19 @@ +AzureSettings__BotName= +AzureSettings__AadAppId= +AzureSettings__AadAppSecret= +AzureSettings__ServiceDnsName= +AzureSettings__CertificateThumbprint= +AzureSettings__InstancePublicPort= +AzureSettings__CallSignalingPort=9441 +AzureSettings__InstanceInternalPort=8445 +AzureSettings__PlaceCallEndpointUrl=https://graph.microsoft.com/v1.0 +AzureSettings__CaptureEvents=false +AzureSettings__PodName=bot-0 +AzureSettings__MediaFolder=archive +AzureSettings__EventsFolder=events +AzureSettings__TopicKey= +AzureSettings__TopicName=recordingbotevents +AzureSettings__RegionName=australiaeast +AzureSettings__IsStereo=false +AzureSettings__WAVSampleRate= +AzureSettings__WAVQuality=100 diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/BotTests/CallHandlerTest.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/BotTests/CallHandlerTest.cs new file mode 100644 index 000000000..6e8d2a2c6 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/BotTests/CallHandlerTest.cs @@ -0,0 +1,149 @@ +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.Graph.Communications.Calls; +using Microsoft.Graph.Communications.Calls.Media; +using Microsoft.Graph.Communications.Common; +using Microsoft.Graph.Communications.Common.OData; +using Microsoft.Graph.Communications.Common.Telemetry; +using Microsoft.Graph.Communications.Resources; +using Microsoft.Graph.Models; +using Microsoft.Skype.Bots.Media; +using Newtonsoft.Json.Bson; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; +using RecordingBot.Model.Constants; +using RecordingBot.Model.Models; +using RecordingBot.Services.Bot; +using RecordingBot.Services.Contract; +using RecordingBot.Services.ServiceSetup; +using RecordingBot.Tests.Helper; +using System; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RecordingBot.Tests.BotTests +{ + [TestFixture] + public class CallHandlerTest + { + private AzureSettings _settings; + + private ICall _call; + private IGraphLogger _logger; + private ILocalMediaSession _mediaSession; + + private IEventPublisher _eventPublisher; + + [OneTimeSetUp] + public void CallHandlerTestOneTimeSetup() + { + _settings = new AzureSettings + { + CaptureEvents = false, + MediaFolder = "archive", + EventsFolder = "events", + IsStereo = false, + AudioSettings = new Services.ServiceSetup.AudioSettings + { + WavSettings = null + }, + }; + + _logger = Substitute.For(); + _eventPublisher = Substitute.For(); + + _mediaSession = Substitute.For(); + _mediaSession.AudioSocket.Returns(Substitute.For()); + + _call = Substitute.For(); + _call.Participants.Returns(Substitute.For()); + _call.Resource.Returns(Substitute.For()); + _call.GraphLogger.Returns(_logger); + _call.MediaSession.Returns(_mediaSession); + + _call.Resource.Source = new ParticipantInfo() + { + Identity = new IdentitySet() + { + User = new Identity() + { + Id = new Guid().ToString() + } + } + }; + } + + [Test] + public void TestOnParticipantUpdate() + { + var jsonSerializerOptions = new JsonSerializerOptions + { + WriteIndented = true, + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.Preserve, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + jsonSerializerOptions.Converters.Add(new ODataJsonConverterFactory(null, null, typeAssemblies: [.. SerializerAssemblies.Assemblies, typeof(SerializableParticipantEvent).Assembly])); + jsonSerializerOptions.Converters.Add(new TypeMappingConverter()); + + var participantCount = 0; + var handler = new CallHandler(_call, _settings, _eventPublisher); + + using (var archive = new ZipFile(Path.Combine("TestData", "participants.zip"))) + { + foreach (ZipEntry file in archive) + { + using (var fileStream = archive.GetInputStream(file)) + { + var json = ((JObject)JToken.ReadFrom(new BsonDataReader(fileStream))).ToString(); + var deserialized = JsonSerializer.Deserialize(json, jsonSerializerOptions); + + Assert.That(deserialized, Is.Not.Null); + + var addedResourceWithUser = deserialized.AddedResources.Where(x => x.Resource.Info.Identity.User != null).ToList(); + var addedResourceWithUserAndAdditionalData = deserialized.AddedResources.Where(x => x.Resource.Info.Identity.User == null && x.Resource.Info.Identity.AdditionalData != null).ToList(); + var addedResourceWithGuestUser = addedResourceWithUserAndAdditionalData.SelectMany(x => x.Resource.Info.Identity.AdditionalData).Where(x => x.Key != "applicationInstance" && x.Value is Identity).ToList(); + var addedResourceWithNonGuestUser = addedResourceWithUserAndAdditionalData.SelectMany(x => x.Resource.Info.Identity.AdditionalData).Where(x => x.Key == "applicationInstance" || x.Value is not Identity).ToList(); + var addedResourceWithoutUserAndAdditionalData = deserialized.AddedResources.Where(x => x.Resource.Info.Identity.User == null && x.Resource.Info.Identity.AdditionalData == null).ToList(); + + var removedResourceWithUser = deserialized.RemovedResources.Where(x => x.Resource.Info.Identity.User != null).ToList(); + var removedResourceWithUserAndAdditionalData = deserialized.RemovedResources.Where(x => x.Resource.Info.Identity.User == null && x.Resource.Info.Identity.AdditionalData != null).ToList(); + var removedResourceWithGuestUser = removedResourceWithUserAndAdditionalData.SelectMany(x => x.Resource.Info.Identity.AdditionalData).Where(x => x.Key != "applicationInstance" && x.Value is Identity).ToList(); + var removedResourceWithNonGuestUser = removedResourceWithUserAndAdditionalData.SelectMany(x => x.Resource.Info.Identity.AdditionalData).Where(x => x.Key == "applicationInstance" || x.Value is not Identity).ToList(); + var removedResourceWithoutUserAndAdditionalData = deserialized.RemovedResources.Where(x => x.Resource.Info.Identity.User == null && x.Resource.Info.Identity.AdditionalData == null).ToList(); + + var c = new CollectionEventArgs("", addedResources: deserialized.AddedResources, updatedResources: null, removedResources: deserialized.RemovedResources); + handler.ParticipantsOnUpdated(null, c); + + var participants = handler.BotMediaStream.GetParticipants(); + + if (addedResourceWithUser.Count != 0) + { + var match = addedResourceWithUser.Count(participants.Contains); + Assert.That(match, Is.EqualTo(addedResourceWithUser.Count)); + } + + if (addedResourceWithGuestUser.Count != 0) + { + var match = participants + .Where(x => x.Resource.Info.Identity.AdditionalData != null) + .SelectMany(x => x.Resource.Info.Identity.AdditionalData) + .Count(participantData => addedResourceWithGuestUser.Any(guest => guest.Value as Identity == participantData.Value as Identity)); + + Assert.That(match, Is.EqualTo(addedResourceWithGuestUser.Count)); + } + + participantCount += addedResourceWithUser.Count + addedResourceWithGuestUser.Count; + participantCount -= removedResourceWithUser.Count + removedResourceWithGuestUser.Count; + + Assert.That(participants.Count, Is.EqualTo(participantCount)); + } + } + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/ConsoleTest/ConsoleMainTest.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/ConsoleTest/ConsoleMainTest.cs new file mode 100644 index 000000000..00b17257d --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/ConsoleTest/ConsoleMainTest.cs @@ -0,0 +1,40 @@ +using NUnit.Framework; +using System; +using System.IO; + +namespace RecordingBot.Tests.ConsoleTest +{ + [TestFixture] + public class ConsoleMainTest + { + private TextWriter _save; + + [OneTimeSetUp] + public void SetupConsoleTest() + { + _save = System.Console.Out; + } + + [OneTimeTearDown] + public void TeardownConsoleTest() + { + System.Console.SetOut(_save); + } + + + [Test] + public void TestVersion() + { + using (StringWriter sw = new()) + { + System.Console.SetOut(sw); + + Console.Program.Main(["-v"]); + + _ = Version.TryParse(sw.ToString(), out Version version); + + Assert.That(version, Is.Not.Null); + } + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Helper/TypeMappingConverter.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Helper/TypeMappingConverter.cs new file mode 100644 index 000000000..e861a2652 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Helper/TypeMappingConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RecordingBot.Tests.Helper +{ + public class TypeMappingConverter : JsonConverter + where TImplementation : TType + { + [return: MaybeNull] + public override TType Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + JsonSerializer.Deserialize(ref reader, options); + + public override void Write( + Utf8JsonWriter writer, TType value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, (TImplementation)value!, options); + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Properties/AssemblyInfo.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..ffaa36b14 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("RecordingBot.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("RecordingBot.Tests")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: Guid("734db860-1a58-40e2-8026-d953c29cb081")] + +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/RecordingBot.Tests.csproj b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/RecordingBot.Tests.csproj new file mode 100644 index 000000000..d415757a7 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/RecordingBot.Tests.csproj @@ -0,0 +1,37 @@ + + + net8.0 + Library + false + x64 + x64 + latest + true + disable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + Always + + + Always + + + \ No newline at end of file diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/participants.zip b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/participants.zip new file mode 100644 index 000000000..b1fde1182 Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/participants.zip differ diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/recording.zip b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/recording.zip new file mode 100644 index 000000000..9b2bffc2b Binary files /dev/null and b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestData/recording.zip differ diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestFixture.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestFixture.cs new file mode 100644 index 000000000..089b49201 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.Tests/TestFixture.cs @@ -0,0 +1,18 @@ +using NUnit.Framework; +using RecordingBot.Services.Bot; +using System.ComponentModel.DataAnnotations; +using System.IO; + +namespace RecordingBot.Tests +{ + [SetUpFixture] + public class TestFixture + { + [OneTimeSetUp] + public void ChangeCurrentDirectory() + { + var dir = Path.GetDirectoryName(typeof(TestFixture).Assembly.Location); + Directory.SetCurrentDirectory(dir); + } + } +} diff --git a/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln b/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln new file mode 100644 index 000000000..12e5bb139 --- /dev/null +++ b/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln @@ -0,0 +1,63 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30011.22 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Services", "RecordingBot.Services\RecordingBot.Services.csproj", "{55C6D645-F418-4262-BEB2-9BAE523DEC60}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Tests", "RecordingBot.Tests\RecordingBot.Tests.csproj", "{734DB860-1A58-40E2-8026-D953C29CB081}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Model", "RecordingBot.Model\RecordingBot.Model.csproj", "{78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{AEB94C04-C6B2-4F11-AB53-9218ABCF2E1F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Console", "RecordingBot.Console\RecordingBot.Console.csproj", "{AEEB866D-E17B-406F-9385-32273D2F8691}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Debug|Any CPU.ActiveCfg = Debug|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Debug|Any CPU.Build.0 = Debug|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Debug|x64.ActiveCfg = Debug|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Debug|x64.Build.0 = Debug|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Release|Any CPU.ActiveCfg = Release|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Release|Any CPU.Build.0 = Release|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Release|x64.ActiveCfg = Release|x64 + {55C6D645-F418-4262-BEB2-9BAE523DEC60}.Release|x64.Build.0 = Release|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Debug|Any CPU.ActiveCfg = Debug|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Debug|Any CPU.Build.0 = Debug|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Debug|x64.ActiveCfg = Debug|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Debug|x64.Build.0 = Debug|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Release|Any CPU.ActiveCfg = Release|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Release|Any CPU.Build.0 = Release|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Release|x64.ActiveCfg = Release|x64 + {734DB860-1A58-40E2-8026-D953C29CB081}.Release|x64.Build.0 = Release|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Debug|Any CPU.ActiveCfg = Debug|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Debug|Any CPU.Build.0 = Debug|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Debug|x64.ActiveCfg = Debug|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Debug|x64.Build.0 = Debug|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Release|Any CPU.ActiveCfg = Release|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Release|Any CPU.Build.0 = Release|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Release|x64.ActiveCfg = Release|x64 + {78FD9AD4-6DA2-4610-9C3C-20CE1B2396E6}.Release|x64.Build.0 = Release|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Debug|Any CPU.ActiveCfg = Debug|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Debug|Any CPU.Build.0 = Debug|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Debug|x64.ActiveCfg = Debug|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Debug|x64.Build.0 = Debug|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Release|Any CPU.ActiveCfg = Release|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Release|Any CPU.Build.0 = Release|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Release|x64.ActiveCfg = Release|x64 + {AEEB866D-E17B-406F-9385-32273D2F8691}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8A4D05AB-AC38-4988-8C91-BE087F1C5328} + EndGlobalSection +EndGlobal diff --git a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/README.md b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/README.md index f7cde2055..dc14e2dee 100644 --- a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/README.md +++ b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/README.md @@ -1,3 +1,7 @@ + +> **⚠️ Deprecation Warning** +> This sample has been deprecated. For an up-to-date sample running on Azure Kubernetes Services, a community provided sample can be found [here](../../../PublicSamples/RecordingBot). + # Introduction The teams-recording-bot sample guides you through building, deploying and testing a Teams recording bot running within a container, deployed into Azure Kubernetes Services. diff --git a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Http/HttpConfigurationInitializer.cs b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Http/HttpConfigurationInitializer.cs index 4edee0242..a6281c09a 100644 --- a/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Http/HttpConfigurationInitializer.cs +++ b/Samples/V1.0Samples/AksSamples(Deprecated)/teams-recording-bot/src/RecordingBot.Services/Http/HttpConfigurationInitializer.cs @@ -34,7 +34,7 @@ public void ConfigureSettings(IAppBuilder app, IGraphLogger logger) { HttpConfiguration httpConfig = new HttpConfiguration(); httpConfig.MapHttpAttributeRoutes(); - httpConfig.MessageHandlers.Add(new LoggingMessageHandler(isIncomingMessageHandler: true, logger: logger, urlIgnorers: new[] { "/logs" })); + httpConfig.MessageHandlers.Add(new LoggingMessageHandler(isIncomingMessageHandler: true, logger: logger, urlIgnorers: new [] { "/logs" })); httpConfig.Services.Add(typeof(IExceptionLogger), new ExceptionLogger(logger));