-
Notifications
You must be signed in to change notification settings - Fork 332
Add pack+sideload Teams app script to activity-protocol postdeploy (#9172) #9188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 28 commits
4f6a5e9
e236622
e7a4c35
b70acb6
41006fd
451f2bf
1408d80
24d5454
2757e1d
1c0f3ae
1b677f5
dc89ae6
f5b4726
6b2de0a
ba95231
f85eb4e
cd21bb5
9291d75
d445022
c1c9eb5
b54f5cc
e64b0c9
4d647d7
5400a6e
f0ee571
b0997f5
6b72194
188a1a9
445345f
695c969
f122be1
1a51292
a58ff27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| #!/usr/bin/env pwsh | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally I'm not a fan of including these scripts, and would like @therealjohn to weigh in when he's back. My concern is that we're signing up to be responsible for debugging any failures that happen with setting up the Teams stuff, and updating it as they make changes. As it is, I also feel like the guide should realistically just be a pointer to an aka.ms link which is maintained by the folks responsible for the Teams side of things, so it can be used by everyone, not just azd users. |
||
| # Pack and sideload the Microsoft Teams app for {{.AgentName}} -- one command. | ||
| # | ||
| # Generated by 'azd deploy' (activity protocol) for agent {{.AgentName}}. Every id below was filled in by | ||
| # azd from the deployed agent and its auto-created Azure Bot, so this script does | ||
| # NOT call Azure -- it just packages and installs the Teams app. | ||
| # | ||
| # What it does (idempotent -- safe to re-run): | ||
| # 1. builds a Teams app package (manifest.json + icons) in a temp dir, | ||
| # 2. ensures the Microsoft 365 Agents Toolkit CLI (atk) is installed, | ||
| # 3. installs the app FOR THE CURRENT USER (atk install --scope Personal -- | ||
| # no org-catalog admin approval needed; your tenant must still allow custom | ||
| # app upload/sideloading, which a Teams admin can enable if it is off), and | ||
| # 4. prints an "Open in Teams" chat deep link. | ||
| # | ||
| # Prerequisites: Node.js (npm) for the atk CLI, and a one-time 'atk auth login m365' | ||
| # with your M365 account (this script launches it for you if you are not signed | ||
| # in). Set SKIP_TEAMS_INSTALL=1 to build the package only and skip the install. | ||
|
|
||
| $ErrorActionPreference = "Stop" | ||
|
|
||
| # ---- Ids baked in by azd deploy (do not edit) ------------------------------- | ||
| $AgentName = "{{.AgentName}}" | ||
| $BotId = "{{.MsaAppID}}" # Teams manifest bots[].botId = the Azure Bot msaAppId | ||
| $TeamsAppId = "{{.TeamsAppId}}" # stable per agent, so re-runs update the same app | ||
|
|
||
| # Teams manifest v1.19 caps name.short at 30 and description.short at 80 chars, | ||
| # but agent names may be longer, so bound the constrained fields. | ||
| $shortName = if ($AgentName.Length -gt 30) { $AgentName.Substring(0, 30) } else { $AgentName } | ||
| $shortDesc = "Chat with $shortName in Microsoft Teams." | ||
| if ($shortDesc.Length -gt 80) { $shortDesc = $shortDesc.Substring(0, 80) } | ||
|
|
||
| # Teams only treats a re-uploaded package (same app id) as an update when the | ||
| # manifest version is higher, so we need a strictly increasing version on every | ||
| # run -- even two runs in the same second. Persist a monotonic build number next | ||
| # to this script (seeded from wall-clock seconds, then always at least | ||
| # last + 1) and encode it into two bounded components (Teams caps each at 65535): | ||
| # minor = N / 65536, patch = N % 65536. N ~ epoch keeps minor < 65535 until ~2106. | ||
| $stateFile = Join-Path $PSScriptRoot ".teams-app-version-$TeamsAppId" | ||
| # Serialize the read-increment-write of the counter so two concurrent runs cannot | ||
| # compute the same version. New-Item -ItemType Directory fails if the directory | ||
| # already exists, giving an atomic create-or-fail lock. We NEVER force-delete the | ||
| # lock to reclaim it: force-removal could evict a live holder or race two waiters. | ||
| # Instead, if we cannot acquire it within the timeout -- which only happens if a | ||
| # crashed run left it behind -- we fall back to an epoch-only version (still | ||
| # monotonic second-over-second) and leave the counter file untouched. The lock | ||
| # directory is created empty and only ever removed by its own holder via a | ||
| # non-recursive delete (which fails on a non-empty directory, so it cannot delete | ||
| # unrelated contents). try/finally guarantees release (PowerShell runs finally | ||
| # blocks even on 'exit'). | ||
| $lockDir = "$stateFile.lock" | ||
| $versionLocked = $false | ||
| $nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() | ||
| $verN = [int64]$nowEpoch | ||
| try { | ||
| $lockWait = 0 | ||
| while ($lockWait -lt 100) { | ||
| try { | ||
| New-Item -ItemType Directory -Path $lockDir -ErrorAction Stop | Out-Null | ||
| $versionLocked = $true | ||
| break | ||
| } catch { | ||
| $lockWait++ | ||
| Start-Sleep -Milliseconds 100 | ||
| } | ||
| } | ||
|
|
||
| # Only bump/persist the monotonic counter while we actually hold the lock. If | ||
| # we fell through without it (stuck lock from a crashed run), use epoch alone. | ||
|
v1212 marked this conversation as resolved.
Outdated
|
||
| if ($versionLocked) { | ||
| $lastN = [int64]0 | ||
| # Only read the counter from a plain regular file -- never follow a symlink | ||
| # or reparse point, and ignore directories. | ||
| $stateItem = Get-Item -LiteralPath $stateFile -Force -ErrorAction SilentlyContinue | ||
| $stateIsLink = $stateItem -and ($stateItem.LinkType -or ($stateItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) | ||
| $stateIsDir = $stateItem -and $stateItem.PSIsContainer | ||
| if ($stateItem -and -not $stateIsLink -and -not $stateIsDir) { | ||
| $raw = (Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue) -replace '[^0-9]', '' | ||
| if ($raw) { $lastN = [int64]$raw } | ||
| } | ||
| $verN = [int64][math]::Max($nowEpoch, $lastN + 1) | ||
| # Persist the counter, but never overwrite a pre-existing symlink/reparse | ||
| # point or directory (a planted link could redirect the write to an | ||
| # arbitrary file). Write to a temp file and move it into place so the link | ||
| # itself is replaced. | ||
| if (-not $stateIsLink -and -not $stateIsDir) { | ||
| $stateTmp = "$stateFile.$PID.tmp" | ||
| Set-Content -LiteralPath $stateTmp -Value ([string]$verN) -NoNewline -ErrorAction SilentlyContinue | ||
| Move-Item -LiteralPath $stateTmp -Destination $stateFile -Force -ErrorAction SilentlyContinue | ||
| } | ||
| } | ||
| } finally { | ||
| if ($versionLocked) { | ||
| # Non-recursive delete: removes our own (empty) lock directory only and | ||
| # fails safely if anything unexpected is inside it. | ||
| try { [System.IO.Directory]::Delete($lockDir, $false) } catch { } | ||
| } | ||
| } | ||
| $verMinor = [int]([math]::Floor($verN / 65536)) | ||
| $verPatch = [int]($verN % 65536) | ||
| $pkgVersion = "1.$verMinor.$verPatch" | ||
| if ($verMinor -gt 65535 -or $verPatch -gt 65535) { | ||
| Write-Error "Computed manifest version $pkgVersion exceeds the Teams component limit (65535)." | ||
| exit 1 | ||
| } | ||
|
|
||
| Write-Host "Agent: $AgentName" | ||
| Write-Host "Bot ID: $BotId" | ||
| Write-Host "Teams app id: $TeamsAppId" | ||
|
|
||
| # ---- Build the Teams app package in a temp dir ------------------------------ | ||
| # Use a unique per-invocation directory (like the Bash script's mktemp -d) so | ||
| # concurrent runs -- including two different projects that share an agent name -- | ||
| # never share a manifest/zip or overwrite each other's package. | ||
| $buildDir = Join-Path ([System.IO.Path]::GetTempPath()) ("teams-app-" + [Guid]::NewGuid().ToString("N")) | ||
| New-Item -ItemType Directory -Force -Path $buildDir | Out-Null | ||
|
|
||
| $manifest = [ordered]@{ | ||
| '$schema' = "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json" | ||
| manifestVersion = "1.19" | ||
| version = $pkgVersion | ||
| id = $TeamsAppId | ||
| developer = [ordered]@{ | ||
| name = "Microsoft Foundry" | ||
| websiteUrl = "https://www.example.com" | ||
| privacyUrl = "https://www.example.com/privacy" | ||
| termsOfUseUrl = "https://www.example.com/termsofuse" | ||
| } | ||
| icons = [ordered]@{ color = "color.png"; outline = "outline.png" } | ||
| name = [ordered]@{ short = $shortName; full = "$AgentName (Activity, Teams)" } | ||
| description = [ordered]@{ | ||
| short = $shortDesc | ||
| full = "A Microsoft Foundry hosted agent on the Activity protocol. Send it a message in Teams." | ||
| } | ||
| accentColor = "#5B5FC7" | ||
| bots = @([ordered]@{ botId = $BotId; scopes = @("personal"); supportsFiles = $false; isNotificationOnly = $false }) | ||
| permissions = @("identity") | ||
| validDomains = @() | ||
| } | ||
| # Write manifest.json as UTF-8 WITHOUT a BOM. Windows PowerShell 5.1's | ||
| # Set-Content -Encoding UTF8 prepends a BOM, which some Teams/atk JSON parsers | ||
| # reject; WriteAllText with an explicit no-BOM encoding is correct on 5.1 and 7. | ||
| $noBomUtf8 = New-Object System.Text.UTF8Encoding($false) | ||
| [System.IO.File]::WriteAllText( | ||
| (Join-Path $buildDir "manifest.json"), ($manifest | ConvertTo-Json -Depth 10), $noBomUtf8) | ||
|
|
||
| # ---- Write the icons (embedded PNGs -- no image tooling needed) -------------- | ||
| # color.png is 192x192, outline.png is 32x32, per the Teams manifest icon rules. | ||
| $colorB64 = "{{.ColorPngB64}}" | ||
| $outlineB64 = "{{.OutlinePngB64}}" | ||
| [System.IO.File]::WriteAllBytes((Join-Path $buildDir "color.png"), [Convert]::FromBase64String($colorB64)) | ||
| [System.IO.File]::WriteAllBytes((Join-Path $buildDir "outline.png"), [Convert]::FromBase64String($outlineB64)) | ||
|
|
||
| # ---- Zip the package (files at the zip root, not in a subfolder) ------------ | ||
| $zipPath = Join-Path $buildDir "$AgentName-teams-app.zip" | ||
| if (Test-Path $zipPath) { Remove-Item $zipPath -Force } | ||
| Compress-Archive ` | ||
| -Path (Join-Path $buildDir "manifest.json"), (Join-Path $buildDir "color.png"), (Join-Path $buildDir "outline.png") ` | ||
| -DestinationPath $zipPath -Force | ||
| Write-Host "Teams app package: $zipPath" | ||
|
|
||
| # Build-only mode: the package (with icons) is ready; skip the atk install. | ||
| if ($env:SKIP_TEAMS_INSTALL -eq "1") { | ||
| Write-Host "SKIP_TEAMS_INSTALL=1 - package built; skipping the per-user Teams install." | ||
| Write-Host "Sideload it manually (requires custom app upload to be enabled for your tenant):" | ||
| Write-Host " $zipPath" | ||
| Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" | ||
| return | ||
| } | ||
|
|
||
| # ---- Ensure the atk CLI is available ---------------------------------------- | ||
| if (-not (Get-Command atk -ErrorAction SilentlyContinue)) { | ||
| if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { | ||
| throw "Node.js/npm is required for the atk CLI (per-user Teams install). Install Node.js, then re-run this script." | ||
| } | ||
| Write-Host "Installing the Microsoft 365 Agents Toolkit CLI (atk)..." | ||
| npm install -g '@microsoft/m365agentstoolkit-cli' | Out-Null | ||
|
v1212 marked this conversation as resolved.
Outdated
|
||
| if (-not (Get-Command atk -ErrorAction SilentlyContinue)) { | ||
| throw "Failed to install atk. Install it manually: npm i -g @microsoft/m365agentstoolkit-cli" | ||
| } | ||
| } | ||
|
|
||
| # atk is a native command; on PowerShell 7.4+ a nonzero exit combined with | ||
| # $ErrorActionPreference = "Stop" would terminate the script before we can inspect | ||
| # the output (e.g. the "not signed in" case). Run the probe installs with | ||
| # non-terminating error handling and return the combined output so the | ||
| # login-and-retry logic below can run. | ||
| function Invoke-AtkInstallProbe { | ||
| param([Parameter(Mandatory)][string]$Zip) | ||
| $ErrorActionPreference = "Continue" | ||
| atk install --file-path "$Zip" --scope Personal --interactive false 2>&1 | Out-String | ||
| } | ||
|
|
||
| # ---- Install the app for the current user (Personal scope) ------------------ | ||
| Write-Host "" | ||
| Write-Host "Installing the Teams app for the current user (atk, scope Personal)..." | ||
| $installOut = Invoke-AtkInstallProbe -Zip $zipPath | ||
| Write-Host $installOut | ||
|
|
||
| # If atk reports the user is not signed in, launch an interactive login and retry. | ||
| if ($installOut -match "(?i)(not\s+(logged|signed)\s+in|auth.*required|please\s+login|login\s+first|no\s+account|cannot\s+get\s+token|log\s+in\s+the\s+correct\s+account)") { | ||
| Write-Host "Not signed in - launching 'atk auth login m365' (complete the sign-in prompt)..." | ||
| atk auth login m365 | ||
| $installOut = Invoke-AtkInstallProbe -Zip $zipPath | ||
| Write-Host $installOut | ||
| } | ||
|
|
||
| $titleId = $null | ||
| $m = [regex]::Match($installOut, 'TitleId:\s*(\S+)') | ||
| if ($m.Success) { $titleId = $m.Groups[1].Value.Trim() } | ||
|
|
||
| # Teams addresses a bot 1:1 as "28:<botId>". Once the app is installed for the | ||
| # user, this opens the conversation with the agent directly. | ||
| $chatLink = "https://teams.microsoft.com/l/chat/0/0?users=28:$BotId" | ||
|
|
||
| if ([string]::IsNullOrWhiteSpace($titleId)) { | ||
| Write-Host "" | ||
| Write-Host "Could not confirm the per-user install." | ||
| Write-Host "If you were prompted to sign in, run 'atk auth login m365' then re-run this script." | ||
| Write-Host "Or sideload the package manually (requires custom app upload to be enabled for your tenant):" | ||
| Write-Host " $zipPath" | ||
| Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" | ||
| # The install did not complete, so report failure (build-only mode already | ||
| # returned earlier). The package + manual steps above remain available. | ||
| exit 1 | ||
| } | ||
|
|
||
| Write-Host "" | ||
| Write-Host "Installed for the current user (titleId: $titleId)." | ||
| Write-Host "Chat with the agent in Teams:" | ||
| Write-Host " $chatLink" | ||
Uh oh!
There was an error while loading. Please reload this page.