From 30a4a6f18eaa52388b404796cec1f899d818b82a Mon Sep 17 00:00:00 2001 From: qfai Date: Mon, 15 Jun 2026 14:12:21 +0800 Subject: [PATCH] chore: sync microsoft-365-agents-toolkit plugin and skill Sources: - Plugin: microsoft/work-iq @ plugins/microsoft-365-agents-toolkit - Skill: OfficeDev/microsoft-365-agents-toolkit @ packages/vscode-extension/skills/microsoft-365-agents-toolkit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft-365-agents-toolkit/README.md | 55 + skills/microsoft-365-agents-toolkit/SKILL.md | 114 ++ .../create-project/create-project.md | 134 ++ .../docs/README.md | 46 + .../docs/advanced-features.md | 149 +++ .../docs/feature-gaps.md | 1120 +++++++++++++++++ .../docs/files-and-links.md | 85 ++ .../docs/identity-and-auth.md | 107 ++ .../docs/infrastructure.md | 178 +++ .../docs/interactive-responses.md | 98 ++ .../docs/messaging-and-commands.md | 102 ++ .../docs/middleware-and-handlers.md | 97 ++ .../docs/ui-components.md | 137 ++ .../docs/workflows.md | 276 ++++ .../experts/README.md | 133 ++ .../experts/_expert-ts.md | 65 + .../experts/analyzer.md | 160 +++ .../bridge/app-distribution-packaging-ts.md | 190 +++ .../experts/bridge/channel-ops-graph-ts.md | 267 ++++ .../experts/bridge/commands-slash-text-ts.md | 410 ++++++ .../bridge/cross-platform-advisor-ts.md | 738 +++++++++++ .../bridge/cross-platform-architecture-ts.md | 165 +++ .../experts/bridge/events-activities-ts.md | 413 ++++++ .../bridge/files-upload-download-ts.md | 332 +++++ .../bridge/identity-oauth-bridge-ts.md | 354 ++++++ .../experts/bridge/index.md | 215 ++++ .../experts/bridge/infra-compute-ts.md | 232 ++++ .../experts/bridge/infra-observability-ts.md | 256 ++++ .../experts/bridge/infra-secrets-config-ts.md | 193 +++ .../experts/bridge/infra-storage-ts.md | 248 ++++ .../bridge/interactive-responses-ts.md | 383 ++++++ .../experts/bridge/link-unfurl-preview-ts.md | 330 +++++ .../experts/bridge/middleware-handlers-ts.md | 328 +++++ .../experts/bridge/python-cross-platform.md | 180 +++ .../bridge/rate-limiting-resilience-ts.md | 367 ++++++ .../bridge/rest-only-integration-ts.md | 150 +++ .../bridge/scheduling-deferred-send-ts.md | 336 +++++ .../experts/bridge/shortcuts-extensions-ts.md | 358 ++++++ .../bridge/transport-socketmode-https-ts.md | 231 ++++ .../bridge/ui-app-home-personal-tab-ts.md | 318 +++++ .../bridge/ui-block-kit-adaptive-cards-ts.md | 453 +++++++ .../bridge/ui-legacy-attachments-cards-ts.md | 206 +++ .../experts/bridge/ui-modals-dialogs-ts.md | 451 +++++++ .../bridge/workflow.composable-platform-ts.md | 297 +++++ .../experts/bridge/workflows-automation-ts.md | 247 ++++ .../experts/builder.md | 182 +++ .../convert/bulk-conversion-strategy-ts.md | 274 ++++ .../experts/convert/dependency-mapping-ts.md | 117 ++ .../experts/convert/index.md | 79 ++ .../experts/convert/java-to-ts-ts.md | 448 +++++++ .../experts/convert/js-to-ts-ts.md | 131 ++ .../experts/convert/json-serialization-ts.md | 223 ++++ .../experts/convert/kotlin-to-ts-ts.md | 212 ++++ .../experts/convert/ruby-to-ts-ts.md | 224 ++++ .../experts/convert/type-mapping-ts.md | 214 ++++ .../experts/deploy/aws-bot-deploy-ts.md | 263 ++++ .../experts/deploy/aws-cli-reference-ts.md | 939 ++++++++++++++ .../experts/deploy/azure-bot-deploy-ts.md | 256 ++++ .../experts/deploy/azure-cli-reference-ts.md | 404 ++++++ .../experts/deploy/index.md | 91 ++ .../experts/fallback.md | 50 + .../experts/index.md | 229 ++++ .../experts/models/anthropic-ts.md | 162 +++ .../experts/models/bedrock-ts.md | 167 +++ .../experts/models/foundry-cloud-ts.md | 142 +++ .../experts/models/foundry-local-ts.md | 215 ++++ .../experts/models/index.md | 110 ++ .../experts/models/openai-azure-openai-ts.md | 161 +++ .../models/oss-openai-compatible-ts.md | 229 ++++ .../experts/models/transformers-js-ts.md | 221 ++++ .../experts/prompt-engineer.md | 225 ++++ .../experts/researcher.md | 58 + .../experts/security/index.md | 34 + .../experts/security/input-validation-ts.md | 229 ++++ .../experts/security/secrets-ts.md | 221 ++++ .../experts/slack/bolt-assistant-ts.md | 169 +++ .../experts/slack/bolt-events-ts.md | 154 +++ .../experts/slack/bolt-java.md | 146 +++ .../slack/bolt-oauth-distribution-ts.md | 180 +++ .../experts/slack/bolt-python.md | 156 +++ .../experts/slack/cli.app-management.md | 158 +++ .../experts/slack/cli.datastore-env.md | 180 +++ .../experts/slack/cli.getting-started.md | 147 +++ .../experts/slack/cli.local-dev-deploy.md | 157 +++ .../experts/slack/cli.manifest-triggers.md | 204 +++ .../experts/slack/index.md | 147 +++ .../experts/slack/runtime.ack-rules-ts.md | 171 +++ .../slack/runtime.bolt-foundations-ts.md | 198 +++ .../experts/slack/runtime.shortcuts-ts.md | 213 ++++ .../slack/runtime.slash-commands-ts.md | 247 ++++ .../experts/slack/runtime.socket-mode-ts.md | 168 +++ .../experts/slack/ui.block-kit-ts.md | 254 ++++ .../experts/slack/ui.modals-lifecycle-ts.md | 251 ++++ .../experts/slack/web-api-proactive-ts.md | 204 +++ .../slack/workflow.slack-automations-ts.md | 181 +++ .../experts/teams/a2a.client-basics-ts.md | 208 +++ .../teams/a2a.orchestrator-patterns-ts.md | 287 +++++ .../experts/teams/a2a.server-basics-ts.md | 256 ++++ .../experts/teams/ai.chatprompt-basics-ts.md | 147 +++ .../experts/teams/ai.citations-feedback-ts.md | 123 ++ .../teams/ai.conversational-query-ts.md | 325 +++++ .../teams/ai.function-calling-design-ts.md | 161 +++ .../ai.function-calling-implementation-ts.md | 148 +++ .../experts/teams/ai.memory-localmemory-ts.md | 155 +++ .../experts/teams/ai.model-setup-ts.md | 101 ++ .../experts/teams/ai.rag-retrieval-ts.md | 138 ++ .../experts/teams/ai.rag-vectorstores-ts.md | 182 +++ .../experts/teams/ai.streaming-ts.md | 123 ++ .../experts/teams/auth.oauth-sso-ts.md | 167 +++ .../teams/compat.botbuilder-interop-ts.md | 246 ++++ .../experts/teams/dev.debug-test-ts.md | 201 +++ .../teams/graph.usergraph-appgraph-ts.md | 150 +++ .../experts/teams/index.md | 191 +++ .../experts/teams/mcp.client-basics-ts.md | 197 +++ .../teams/mcp.expose-chatprompt-tools-ts.md | 216 ++++ .../experts/teams/mcp.security-ts.md | 186 +++ .../experts/teams/mcp.server-basics-ts.md | 210 ++++ .../teams/project.scaffold-files-ts.md | 218 ++++ .../experts/teams/runtime.app-init-ts.md | 167 +++ .../experts/teams/runtime.manifest-ts.md | 208 +++ .../teams/runtime.proactive-messaging-ts.md | 228 ++++ .../teams/runtime.routing-handlers-ts.md | 221 ++++ .../teams/state.storage-patterns-ts.md | 194 +++ .../experts/teams/teams-dotnet.md | 146 +++ .../experts/teams/teams-python.md | 137 ++ .../experts/teams/ui.adaptive-cards-ts.md | 281 +++++ .../teams/ui.dialogs-task-modules-ts.md | 369 ++++++ .../experts/teams/ui.message-extensions-ts.md | 348 +++++ .../teams/workflow.approvals-inline-ts.md | 294 +++++ .../workflow.message-native-records-ts.md | 233 ++++ .../teams/workflow.sharepoint-lists-ts.md | 138 ++ .../teams/workflow.state-driven-events-ts.md | 231 ++++ .../teams/workflow.triggers-compose-ts.md | 216 ++++ .../experts/update-experts.md | 173 +++ .../provision-deploy/provision-deploy.md | 94 ++ .../slack-to-teams/SKILL.md | 168 +++ .../test-playground/playground-cli.md | 148 +++ .../test-playground/playground.md | 132 ++ .../test-playground/test-playground.md | 21 + .../test-teams/test-teams.md | 123 ++ .../toolkit/README.md | 44 + .../toolkit/commands.md | 113 ++ .../toolkit/environments.md | 215 ++++ .../toolkit/lifecycle-cli.md | 312 +++++ .../toolkit/manifest-and-yaml.md | 105 ++ .../toolkit/playground.md | 128 ++ .../toolkit/publish.md | 150 +++ .../toolkit/templates.md | 164 +++ .../troubleshoot/troubleshoot.md | 466 +++++++ 149 files changed, 32202 insertions(+) create mode 100644 plugins/microsoft-365-agents-toolkit/README.md create mode 100644 skills/microsoft-365-agents-toolkit/SKILL.md create mode 100644 skills/microsoft-365-agents-toolkit/create-project/create-project.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/README.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/advanced-features.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/feature-gaps.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/files-and-links.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/identity-and-auth.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/infrastructure.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/interactive-responses.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/messaging-and-commands.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/middleware-and-handlers.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/ui-components.md create mode 100644 skills/microsoft-365-agents-toolkit/docs/workflows.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/README.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/_expert-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/analyzer.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/app-distribution-packaging-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/channel-ops-graph-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/commands-slash-text-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-advisor-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-architecture-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/events-activities-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/files-upload-download-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/identity-oauth-bridge-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/infra-compute-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/infra-observability-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/infra-secrets-config-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/infra-storage-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/interactive-responses-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/link-unfurl-preview-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/middleware-handlers-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/python-cross-platform.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/rate-limiting-resilience-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/rest-only-integration-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/scheduling-deferred-send-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/shortcuts-extensions-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/transport-socketmode-https-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/ui-app-home-personal-tab-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/ui-block-kit-adaptive-cards-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/ui-legacy-attachments-cards-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/ui-modals-dialogs-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/workflow.composable-platform-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/bridge/workflows-automation-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/builder.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/bulk-conversion-strategy-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/dependency-mapping-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/java-to-ts-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/js-to-ts-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/json-serialization-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/kotlin-to-ts-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/ruby-to-ts-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/convert/type-mapping-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/deploy/aws-bot-deploy-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/deploy/aws-cli-reference-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/deploy/azure-bot-deploy-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/deploy/azure-cli-reference-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/deploy/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/fallback.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/anthropic-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/bedrock-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/foundry-cloud-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/foundry-local-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/openai-azure-openai-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/oss-openai-compatible-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/models/transformers-js-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/prompt-engineer.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/researcher.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/security/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/security/input-validation-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/security/secrets-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/bolt-assistant-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/bolt-events-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/bolt-java.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/bolt-oauth-distribution-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/bolt-python.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/cli.app-management.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/cli.datastore-env.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/cli.getting-started.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/cli.local-dev-deploy.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/cli.manifest-triggers.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/runtime.ack-rules-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/runtime.bolt-foundations-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/runtime.shortcuts-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/runtime.slash-commands-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/runtime.socket-mode-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/ui.block-kit-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/ui.modals-lifecycle-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/web-api-proactive-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/slack/workflow.slack-automations-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/a2a.client-basics-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/a2a.orchestrator-patterns-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/a2a.server-basics-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.chatprompt-basics-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.citations-feedback-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.conversational-query-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.function-calling-design-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.function-calling-implementation-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.memory-localmemory-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.model-setup-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.rag-retrieval-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.rag-vectorstores-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ai.streaming-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/auth.oauth-sso-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/compat.botbuilder-interop-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/dev.debug-test-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/graph.usergraph-appgraph-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/index.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/mcp.client-basics-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/mcp.expose-chatprompt-tools-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/mcp.security-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/mcp.server-basics-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/project.scaffold-files-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/runtime.app-init-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/runtime.manifest-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/runtime.proactive-messaging-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/runtime.routing-handlers-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/state.storage-patterns-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/teams-dotnet.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/teams-python.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ui.adaptive-cards-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ui.dialogs-task-modules-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/ui.message-extensions-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/workflow.approvals-inline-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/workflow.message-native-records-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/workflow.sharepoint-lists-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/workflow.state-driven-events-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/teams/workflow.triggers-compose-ts.md create mode 100644 skills/microsoft-365-agents-toolkit/experts/update-experts.md create mode 100644 skills/microsoft-365-agents-toolkit/provision-deploy/provision-deploy.md create mode 100644 skills/microsoft-365-agents-toolkit/slack-to-teams/SKILL.md create mode 100644 skills/microsoft-365-agents-toolkit/test-playground/playground-cli.md create mode 100644 skills/microsoft-365-agents-toolkit/test-playground/playground.md create mode 100644 skills/microsoft-365-agents-toolkit/test-playground/test-playground.md create mode 100644 skills/microsoft-365-agents-toolkit/test-teams/test-teams.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/README.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/commands.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/environments.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/lifecycle-cli.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/manifest-and-yaml.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/playground.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/publish.md create mode 100644 skills/microsoft-365-agents-toolkit/toolkit/templates.md create mode 100644 skills/microsoft-365-agents-toolkit/troubleshoot/troubleshoot.md diff --git a/plugins/microsoft-365-agents-toolkit/README.md b/plugins/microsoft-365-agents-toolkit/README.md new file mode 100644 index 000000000..329299966 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/README.md @@ -0,0 +1,55 @@ +# M365 Agents Toolkit + +Toolkit for building Microsoft 365 Copilot declarative agents. + +## Installation + +### Via GitHub Copilot CLI Plugin Marketplace + +```bash +/plugin install microsoft-365-agents-toolkit@work-iq +``` + +## Usage + +``` +# Develop an agent +"Scaffold a new declarative agent for HR FAQ" + +# Configure capabilities +"Add web search to my agent" + +# Deploy +"Deploy my agent with ATK" + +# Create evals +"Create an eval suite for my agent based on it's capabilities." + +# Run evals +"Run my evals for the agent" + +# Analyze and improve +"Analyze the evaluation failures by root cause, and recommend targeted agent instruction changes" + +# Regression check after agent changes +"I changed my agent instructions. Re-run the evals with stable concurrency and compare the new results to .evals\baseline.json" +``` + +The evaluator skill uses the public preview M365 Copilot eval CLI through package-scoped `npx`. Learn more about the preview, docs, issues, and feedback channels in the public [m365-copilot-eval repository](https://github.com/microsoft/m365-copilot-eval). + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\latest.json +``` + +## Skills + +| Skill | What It Does | +|-------|-------------| +| [**install-atk**](./skills/install-atk/SKILL.md) | Install or update the ATK CLI and VS Code extension | +| [**declarative-agent-developer**](./skills/declarative-agent-developer/SKILL.md) | Scaffolding, JSON manifest authoring, capability configuration, security patterns, deployment via ATK CLI | +| [**ui-widget-developer**](./skills/ui-widget-developer/SKILL.md) | Build MCP servers with OpenAI Apps SDK widget rendering for Copilot Chat | +| [**m365-agent-evaluator**](./skills/m365-agent-evaluator/SKILL.md) | Generate, run, and analyze evaluation suites for M365 Copilot declarative agents | + +## License + +See the root [LICENSE](../../LICENSE) file. diff --git a/skills/microsoft-365-agents-toolkit/SKILL.md b/skills/microsoft-365-agents-toolkit/SKILL.md new file mode 100644 index 000000000..7a882dcb5 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/SKILL.md @@ -0,0 +1,114 @@ +--- +name: microsoft-365-agents-toolkit +description: "Builds, tests, and deploys Microsoft 365 apps and agents for Teams and Copilot. Includes sub-skills for project creation, local testing, cloud deployment, troubleshooting, and Slack-to-Teams migration. USE FOR: Teams agent, bot, tab, message extension, Declarative Agents, Custom Engine Agents, local testing, Agents Playground, Azure resource provision, remote deployment, Slack to Teams migration, cross-platform bot development, Block Kit to Adaptive Cards conversion. DO NOT USE FOR: general web development, non-bot/non-Teams projects." +--- + +# Microsoft 365 Agents Toolkit Skill + +Build Microsoft 365 agents and Teams apps using the ATK CLI. + +## AI Behavior Guidelines + +1. **Testing Strategy:** Recommend Agents Playground first (faster, no M365 needed). Use Teams workflow only if user explicitly requests it. + +2. **Environment Variables:** NEVER hardcode secrets or make up placeholder values. Always ask users for real values. + +3. **Error Handling:** Read error messages carefully. Check `env/.env.local`, `.localConfigs`, and `atk auth list`. Common pitfalls: + - **`AADSTS7000229`** → `aadApp/create` missing `generateServicePrincipal: true` in YAML — add it and re-provision + - **Missing `TENANT_ID`** in `.localConfigs` → SDK uses wrong token authority → 401 from Bot Connector + - **401 persists after auth fix** → devtunnel URL may be blacklisted — create a fresh tunnel + - See [troubleshoot/troubleshoot.md](troubleshoot/troubleshoot.md) for full diagnostic steps + +4. **Long-Running Commands — WAIT for completion:** + - `atk new`, `atk provision`, `atk deploy` can take several minutes + - Always wait for completion before running the next step (timeout 120000ms+) + +5. **Local Service Startup — Hangs terminal (expected):** + - `npm run dev`, `npm start`, `python app.py`, `devtunnel host`, etc. will hang — the process keeps running indefinitely + - ALWAYS run as a background process (`isBackground=true`) — NEVER use `isBackground=false` for these commands + - Do NOT wait for it to "finish" — verify startup by checking output for "listening on port" or tunnel URL + - If errors appear, read logs, diagnose, fix, restart + - Use a **NEW terminal** to launch Agents Playground or open Teams sideloading URL + +6. **Monitor App Logs:** Periodically check background terminal output for runtime errors. If the app crashes, read the error, fix the root cause, and restart. + +7. **Telemetry Tagging:** Before running any `atk` CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated: + ```bash + export ATK_CLI_SKILL=true + ``` + Run this once at the start of the session. All subsequent `atk` commands in the same terminal will inherit it. + +## ATK CLI Setup + +```bash +atk --version # Must be > 1.1.5-beta +``` + +If ATK is not found or version is too old: +```bash +npm i -g @microsoft/m365agentstoolkit-cli@beta +``` + +## CLI Global Options + +| Option | Meaning | Recommendation | +| --- | --- | --- | +| `-i` | Interactive mode | Always use `-i false` in automation to avoid hanging | +| `-f` | Project folder | Default to be current directory, used when specifying a custom folder. When scaffolding a new project, this is the parent folder where the project folder will be created under. | +| `-h` | Command help | Use `atk -h` for quick syntax checks | + +## Sub-Skills + +| Sub-Skill | When to Use | Reference | +|-----------|-------------|-----------| +| **create-project** | Scaffold new project from template, choose template, `atk new` | [create-project/create-project.md](create-project/create-project.md) | +| **test-playground** | Test locally with Agents Playground, `agentsplayground`, quick testing | [test-playground/test-playground.md](test-playground/test-playground.md) | +| **test-teams** | Run on Teams, devtunnel, sideload, Teams testing, test in Copilot | [test-teams/test-teams.md](test-teams/test-teams.md) | +| **provision-deploy** | Provision Azure resources, deploy to cloud, `atk provision`, `atk deploy` | [provision-deploy/provision-deploy.md](provision-deploy/provision-deploy.md) | +| **troubleshoot** | Fix errors, 401, port conflicts, YAML errors, stale bots | [troubleshoot/troubleshoot.md](troubleshoot/troubleshoot.md) | +| **slack-to-teams** | Migrate Slack bot to Teams, cross-platform bridging, Block Kit to Adaptive Cards | [slack-to-teams/SKILL.md](slack-to-teams/SKILL.md) | + +> **MANDATORY:** Before executing any workflow, read the corresponding sub-skill document. + +## Shared References + +- [manifest-and-yaml.md](toolkit/manifest-and-yaml.md) — Project files, YAML config, env vars, .localConfigs flow +- [commands.md](toolkit/commands.md) — ATK CLI commands: package, validate, share, collaborate +- [templates.md](toolkit/templates.md) — Complete template catalog with language support +- [experts/](experts/index.md) — 100+ micro-expert files: Teams SDK, Slack SDK, cross-platform bridging, deploy, AI models, security, language conversion +- [docs/](docs/README.md) — Platform comparison guides: UI, messaging, identity, infrastructure, feature gaps + +## Workflow Chains + +Match user intent to the smallest valid workflow. + +| User Intent | Workflow (read in order) | +|---|---| +| Build new app from scratch | create-project → test-playground | +| Test existing project locally | test-playground (recommended) or test-teams | +| Deploy to Azure | provision-deploy | +| Fix broken bot | troubleshoot → re-test | +| Migrate Slack bot to Teams | slack-to-teams | + +> **MANDATORY:** Before executing any slack-to-teams workflow, read [slack-to-teams/SKILL.md](slack-to-teams/SKILL.md) first. The sub-skill contains a routed expert system with 100+ micro-expert files for cross-platform bot development. + +## ATK Project Context Resolution + +Resolve config values only when missing. If a value is already known in the session, reuse it. + +### Step 1: Detect ATK Project + +If `m365agentstoolkit*.yml` exists in the current folder, treat it as an ATK project and parse configuration. + +### Step 2: Resolve Common Configuration + +Resolve variables referenced in `m365agentstoolkit*.yml`. Common variables: +AZURE_OPENAI_API_KEY +AZURE_OPENAI_ENDPOINT +AZURE_OPENAI_DEPLOYMENT_NAME + +### Step 3: Collect Missing Values + +If required values are missing, ask the user for only the missing ones. + +Refer to [manifest-and-yaml.md](toolkit/manifest-and-yaml.md) for full config-file details. \ No newline at end of file diff --git a/skills/microsoft-365-agents-toolkit/create-project/create-project.md b/skills/microsoft-365-agents-toolkit/create-project/create-project.md new file mode 100644 index 000000000..4e9ffe14b --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/create-project/create-project.md @@ -0,0 +1,134 @@ +# Create Project + +Scaffold a new Microsoft 365 agent or Teams app from an ATK template. + +## Template Selection Guide + +| User Wants | Capability | +|------------|------------| +| Extend M365 Copilot with custom instructions | `declarative-agent` | +| Declarative Agent with new API | `declarative-agent-action` | +| Declarative Agent with new API (Bearer Token) | `declarative-agent-action-bearer` | +| Declarative Agent with new API (OAuth) | `declarative-agent-action-oauth` | +| Declarative Agent with existing OpenAPI spec | `declarative-agent-action-from-existing-api` | +| Connect MCP Server to Copilot | `declarative-agent-with-action-from-mcp` | +| Declarative Agent with Copilot Connector | `declarative-agent-with-graph-connector` | +| Declarative Agent for MetaOS | `declarative-agent-meta-os-new-project` | +| Declarative Agent from TypeSpec | `declarative-agent-typespec` | +| Agent with custom LLM (Azure OpenAI, etc.) | `basic-custom-engine-agent` | +| Weather forecast agent | `weather-agent` | +| Agent using Azure AI Foundry | `foundry-agent-to-m365` | +| Teams chatbot with AI | `teams-agent` | +| Teams bot with RAG/knowledge base | `teams-agent-rag-customize` | +| Teams Agent with Azure AI Search | `teams-agent-rag-azure-ai-search` | +| Teams Agent with Custom API | `teams-agent-rag-custom-api` | +| Teams Collaborator Agent | `teams-collaborator-agent` | +| Simple Teams echo bot | `bot` | +| Teams tab app | `tab` | +| Teams message extension | `message-extension` | +| Copilot Connector | `copilot-connector` | + +See [../toolkit/templates.md](../toolkit/templates.md) for the complete template catalog with language support and descriptions. + +## Creating Projects + +Create templates in the current directory with one generic flow: + +```bash +# 1) Scaffold into a temporary parent folder +atk new -c -n -f /tmp -l -i false + +# 2) Move generated files from the scaffold subfolder into current directory +mv /tmp//. . + +# 3) Remove the empty scaffold folder +rmdir /tmp/ +``` + +Common examples: + +```bash +# Declarative Agent (no -l needed) +atk new -c declarative-agent -n my-agent -f /tmp -i false + +# Declarative Agent with new API +atk new -c declarative-agent-action -l typescript -n my-api-agent -f /tmp -i false + +# Declarative Agent with existing OpenAPI spec +atk new -c declarative-agent-action-from-existing-api -n my-agent -a -o "GET /repairs" -o "POST /repairs" -f /tmp -i false + +# Custom Engine Agent +atk new -c basic-custom-engine-agent -l typescript -n my-cea -f /tmp -i false + +# Teams Agent with RAG +atk new -c teams-agent-rag-customize -l typescript -n my-rag-agent -f /tmp -i false +``` + +PowerShell equivalent: + +```powershell +# 1) Scaffold into temporary folder +atk new -c -n -f $env:TEMP -l -i false + +# 2) Move files into current directory +Move-Item "$env:TEMP\\*" . +Move-Item "$env:TEMP\\.*" . -ErrorAction SilentlyContinue + +# 3) Remove scaffold folder +Remove-Item "$env:TEMP\" -Force +``` + +## Creating from Samples + +```bash +atk new sample +``` + +To place sample files in current directory, scaffold first and then move files from the sample output folder into `.` using the same move pattern as above. + +| Sample | Sample ID (`atk new sample `) | Tags | +| ------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- | +| Langchain Agent with Agent365 SDK in NodeJS | `agent365-langchain-nodejs` | Agent365, TS | +| Agent Framework Agent with Agent365 SDK in Python | `agent365-agentframework-python` | Agent365, Python | +| OpenAI Agent with Agent365 SDK in Python | `agent365-openai-python` | Agent365, Python | +| Claude Agent with Agent365 SDK in NodeJS | `agent365-claude-nodejs` | Agent365, TS | +| Tab App with Azure Backend | `hello-world-tab-with-backend` | Tab, TS, Azure Functions, Dev Proxy | +| Bot App with SSO Enabled | `bot-sso` | Bot, TS, Adaptive Cards, SSO | +| Team Central Dashboard | `team-central-dashboard` | Tab, TS, Azure Functions, SSO | +| Copilot connector App | `copilot-connector-app` | Tab, Azure Functions, TS, SSO, Copilot connector | +| Teams Conversation Bot using Python | `bot-conversation-python` | Python, Bot, Bot Framework | +| Teams Messaging Extensions Search using Python | `msgext-search-python` | Python, Message extension, Bot Framework | +| Travel Agent | `travel-agent` | C#, Custom Engine Agent, M365 Copilot Retrieval API, Agents SDK, Agent Framework | +| Coffee Agent | `coffee-agent` | TS, Custom Engine Agent, Adaptive Cards, Microsoft Teams SDK | +| Data Analyst Agent v2 | `data-analyst-agent-v2` | TS, Custom Engine Agent, Data Visualization, Adaptive Cards, LLM SQL, Microsoft Teams SDK | + +List all samples with `atk list samples`. + +## Notes + +- `declarative-agent` does NOT require `-l` language flag +- `declarative-agent-action-from-existing-api` requires `-a` (OpenAPI spec) and `-o` (operation IDs like `"GET /path"`) +- Always use `-i false` for non-interactive scripted creation +- `atk new` can take several minutes — wait for completion (timeout 120000ms+) +- If template/sample already matches the requirement, do not run dependency install by default; continue only when user asks for next steps + +## After Scaffolding + +Once the project is created: +- To test locally → see [../test-playground/test-playground.md](../test-playground/test-playground.md) +- To understand project files → see [../toolkit/manifest-and-yaml.md](../toolkit/manifest-and-yaml.md) + +## Expert Deep Dives + +> **Applies to: code-based Teams bots/agents only** (templates: `bot`, `teams-agent*`, `basic-custom-engine-agent`, `weather-agent`, `coffee-agent`, `bot-sso`, `msgext-*`, `tab*`). +> +> Does **not** apply to declarative agents, API plugins, Copilot connectors, or `declarative-agent-*` / `copilot-connector` templates — those have no source code to scaffold against. For those, follow the in-template instructions and the [Microsoft 365 Copilot extensibility docs](https://learn.microsoft.com/microsoft-365-copilot/extensibility/) directly. + +For deeper guidance on what `atk new` produces and how to extend it, consult the Teams expert micro-files: + +| Topic | Expert | +|---|---| +| Project file layout, `package.json`, `tsconfig.json`, npm scripts, `appPackage/` | [../experts/teams/project.scaffold-files-ts.md](../experts/teams/project.scaffold-files-ts.md) | +| `App` constructor, plugins, credentials, runtime initialization | [../experts/teams/runtime.app-init-ts.md](../experts/teams/runtime.app-init-ts.md) | +| Teams app manifest schema, scopes, bots/composeExtensions/staticTabs | [../experts/teams/runtime.manifest-ts.md](../experts/teams/runtime.manifest-ts.md) | +| Routing handlers (`app.on('message')`, activity types, invokes) | [../experts/teams/runtime.routing-handlers-ts.md](../experts/teams/runtime.routing-handlers-ts.md) | diff --git a/skills/microsoft-365-agents-toolkit/docs/README.md b/skills/microsoft-365-agents-toolkit/docs/README.md new file mode 100644 index 000000000..3cad2f310 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/README.md @@ -0,0 +1,46 @@ +# Slack vs Teams: Platform Differences & Bridging Strategies + +A practical guide for developers adding cross-platform support to an existing bot. Each document covers a category of differences, explains why they matter, and provides concrete mitigation strategies with effort estimates. + +## Documents + +| Document | What It Covers | +|---|---| +| [**Feature Gaps**](feature-gaps.md) | **Complete inventory of every RED and YELLOW gap with mitigations in both directions** | +| [**Workflows**](workflows.md) | **Message-native workflow scenarios: standup, PTO, equipment, account health, break management, incidents** | +| [Messaging & Commands](messaging-and-commands.md) | Messages, slash commands, events, threading, @mentions | +| [UI Components](ui-components.md) | Block Kit vs Adaptive Cards, modals vs dialogs, App Home vs personal tabs | +| [Interactive Responses](interactive-responses.md) | Ephemeral messages, button actions, message updates, confirmation dialogs | +| [Identity & Auth](identity-and-auth.md) | User IDs, OAuth, signing/verification, tokens | +| [Files & Links](files-and-links.md) | File upload/download, link unfurling/previews | +| [Middleware & Handler Patterns](middleware-and-handlers.md) | Middleware chains, ack(), handler registration, error handling | +| [Advanced Features](advanced-features.md) | Scheduling, workflows, shortcuts, channel ops, reactions, distribution | +| [Infrastructure](infrastructure.md) | Transport, compute, storage, secrets, observability | +| [**Eval Harness**](../evals/README.md) | Automated testing for expert routing, completeness, and code patterns | + +## Eval Harness + +The [`evals/`](../evals/) directory contains an automated test harness for the expert system. It validates three dimensions: + +- **Routing** — 51 test cases across all 7 domains verify queries route to the correct domain, clusters, and expert files +- **Completeness** — 9 test cases check experts cover all required concepts for their domain +- **Patterns** — 294 TypeScript code blocks across all experts are compiled in-memory to catch syntax errors + +Pattern evals are fully deterministic (no API key needed). Routing and completeness evals use an LLM judge (OpenAI, Anthropic, or Azure OpenAI). See [`evals/README.md`](../evals/README.md) for setup and usage. + +## How to Read These Docs + +Each difference follows this format: + +- **What's different** — the concrete behavioral gap +- **Impact** — what breaks or degrades if you ignore it +- **Mitigation** — one or more strategies ranked by effort and fidelity +- **Effort** — rough hours to implement + +### Difficulty Ratings + +| Rating | Meaning | +|---|---| +| GREEN | Direct mapping exists. Mechanical conversion, minimal design decisions. | +| YELLOW | Mapping exists but requires design decisions or trade-offs. | +| RED | Platform gap — no equivalent exists. Requires redesign or custom workaround. | diff --git a/skills/microsoft-365-agents-toolkit/docs/advanced-features.md b/skills/microsoft-365-agents-toolkit/docs/advanced-features.md new file mode 100644 index 000000000..9650eb1cb --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/advanced-features.md @@ -0,0 +1,149 @@ +# Advanced Features + +## Scheduled Messages + +| Aspect | Slack | Teams | +|---|---|---| +| Native API | `chat.scheduleMessage()` | **No equivalent** | +| Cancel scheduled | `chat.deleteScheduledMessage()` | N/A | +| Reminders | `reminders.add()` | **No equivalent** | + +**Rating:** RED (Slack → Teams), GREEN (Teams → Slack). + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Azure Functions timer + Cosmos DB (Recommended)** | Store scheduled message in Cosmos DB. Azure Functions timer trigger polls and sends via proactive messaging. | 16–24 hrs | +| **Azure Queue visibility timeout** | Set visibility timeout to delay message processing. 7-day maximum. | 8–12 hrs | +| **Azure Service Bus scheduled messages** | Best for high-volume exact-time delivery. | 12–16 hrs | +| **Power Automate** | Offload to Power Automate flows with "Delay until" action. Requires license. | 8–12 hrs | +| **In-process timer (dev only)** | `setTimeout` / `node-cron`. Not durable — lost on restart. | 2–4 hrs | + +### Reverse Direction (Teams → Slack) + +Use `chat.scheduleMessage()` and `reminders.add()` directly — native APIs. + +--- + +## Emoji Reactions + +| Aspect | Slack | Teams | +|---|---|---| +| Event | `reaction_added` / `reaction_removed` | `messageReaction` | +| Reaction types | Unlimited custom emoji | **6 fixed reactions only**: like, heart, laugh, surprised, sad, angry | +| Workflow use | Common to use reactions as workflow signals (e.g., `:white_check_mark:` = approved) | Not viable — too few options | + +**Rating:** RED (Slack → Teams) if reactions are used as workflow signals. + +### Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Adaptive Card buttons (Recommended)** | Replace reaction-based workflows with `Action.Submit` buttons on cards (e.g., "Approve" / "Reject"). Better for audit trails. | 4–8 hrs | +| **Map to 6 fixed reactions** | Map your most important reactions to like/heart/laugh/surprised/sad/angry. Lossy — only works if you use ≤6 reactions. | 2–4 hrs | + +### Reverse Direction (Teams → Slack) + +Slack supports unlimited custom emoji reactions — direct mapping. + +--- + +## Shortcuts / Message Extensions + +| Aspect | Slack | Teams | +|---|---|---| +| Global shortcut | `app.shortcut("callback_id")` | Compose extension with `context: ["compose", "commandBox"]` | +| Message shortcut | `app.shortcut("callback_id")` (type: `message_shortcut`) | Action extension with `context: ["message"]` | +| Fire-and-forget | Supported (ack + background work) | **Not supported** — must open task module | +| Manifest config | Shortcut in app settings | `composeExtensions[].commands[]` | +| Message context | `shortcut.message` | `activity.value.messagePayload` | + +**Rating:** YELLOW — functional equivalents exist but UX differs. + +### Key Difference + +Slack shortcuts can run background actions without showing UI (ack + do work). Teams compose/action extensions always open a task module — there's no fire-and-forget pattern. Use a "minimal dismiss" pattern: return a tiny "Done" card that auto-closes. + +### Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Compose extension (Recommended)** | `composeExtensions` with `commandBox` context. Opens task module. | 8–12 hrs | +| **Minimal-dismiss pattern** | Task module returns tiny "Done" card for fire-and-forget actions. | 4–8 hrs | +| **Bot command replacement** | Replace shortcut with typed command. Simpler but less discoverable. | 2–4 hrs | + +--- + +## Channel Operations + +| Aspect | Slack | Teams | +|---|---|---| +| Create channel | `conversations.create()` | Graph `POST /teams/{team-id}/channels` | +| Archive channel | `conversations.archive()` | **No equivalent** — Teams can only archive entire Teams | +| Set topic | `conversations.setTopic()` | Graph `PATCH /channels/{id}` with `description` | +| Invite member | `conversations.invite()` | Graph `POST /channels/{id}/members` (one call per member) | +| Remove member | `conversations.kick()` | Graph `DELETE /channels/{id}/members/{membership-id}` (must resolve membership ID first) | +| Channel namespace | Flat (channel ID is globally unique) | Team-scoped (need `team-id` + `channel-id`) | +| Channel name limits | 80 chars, most characters allowed | 50 chars, no special characters | + +**Rating:** GREEN for create/topic/invite, YELLOW for remove (membership ID resolution), RED for archive. + +### Archive Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Rename with [ARCHIVED] prefix (Recommended)** | Rename channel, update description. Cosmetic but non-destructive. | 4–8 hrs | +| **Rename + remove all members** | Stronger enforcement but destructive — members must be re-invited to undo. | 8–12 hrs | +| **Team-level archive** | Archive entire Team. Only works if channel is in a dedicated Team. | 2–4 hrs | + +--- + +## Workflows / Automation + +| Aspect | Slack | Teams | +|---|---|---| +| Platform | Workflow Builder (free) | Power Automate (licensed for premium connectors) | +| Bot integration | `workflow_step_execute` event | Custom connectors or bot-driven orchestration | +| Triggers | Channel message, emoji reaction, scheduled, webhook | Same + Approvals connector, Planner, SharePoint | +| Migration tool | N/A | **None** — manual rebuild required | + +**Rating:** YELLOW — functional equivalent exists but different platform, possible licensing. + +### Mitigation Strategies + +| Strategy | How | Effort | +|---|---|---| +| **Bot-driven orchestration (Recommended)** | Keep workflow logic in the bot. State machine + Adaptive Card buttons + persistent storage. No license dependency. | 16–40 hrs | +| **Power Automate rebuild** | Rebuild in Power Automate. Custom steps need Premium license. | 24–80 hrs | +| **Hybrid** | Simple flows → Power Automate, complex → bot-driven. | Varies | +| **Teams Workflows app** | Simplified UI for basic automations (free). Limited to simple scenarios. | 4–8 hrs | + +--- + +## App Distribution + +| Aspect | Slack | Teams | +|---|---|---| +| Directory listing | Slack App Directory (api.slack.com) | Teams App Store via Partner Center | +| Review time | Hours to days | 1–2 weeks | +| Org-level install | Workspace admin approval | Teams Admin Center tenant-wide deployment | +| Dev install | Direct install via OAuth URL | Sideloading (ZIP with manifest + icons) | +| Required assets | App icon | 192x192 full-color icon + 32x32 monochrome outline | +| Multi-tenant | Per-workspace tokens via `InstallationStore` | `signInAudience: "AzureADMultipleOrgs"` in Azure AD | + +**Rating:** YELLOW — both have distribution mechanisms but packaging and review differ. + +### Sideloading (Dev/Test) + +Teams sideloading requires: +1. `manifest.json` (schema v1.19+) +2. `color.png` (192x192) +3. `outline.png` (32x32 monochrome) +4. ZIP all three files +5. Upload via Teams client → Apps → Manage your apps → Upload +6. Note: Sideloading may be disabled by admin — check tenant settings + +### Reverse Direction (Teams → Slack) + +Submit to Slack App Directory via api.slack.com. Implement `InstallProvider` for OAuth install flow. Shorter review cycle. diff --git a/skills/microsoft-365-agents-toolkit/docs/feature-gaps.md b/skills/microsoft-365-agents-toolkit/docs/feature-gaps.md new file mode 100644 index 000000000..ad27e6c49 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/feature-gaps.md @@ -0,0 +1,1120 @@ +# Feature Gap Analysis: Slack ↔ Teams + +A complete inventory of every feature that does **not** have a direct equivalent on the other platform, organized by severity. Each gap includes mitigations in both directions. + +## How to Read This Document + +- **Slack → Teams** = you have a Slack bot and are adding Teams support +- **Teams → Slack** = you have a Teams bot and are adding Slack support +- Effort estimates are per-feature implementation hours +- Features with direct 1:1 mappings (GREEN) are not listed — see [messaging-and-commands.md](messaging-and-commands.md) and [ui-components.md](ui-components.md) for those + +--- + +## RED Gaps — No Platform Equivalent + +These features exist on one platform with **no counterpart** on the other. They require redesign, custom infrastructure, or acceptance of reduced functionality. + +--- + +### R1. Ephemeral Messages + +**Slack has it. Teams does not.** + +Slack's `chat.postEphemeral()` sends a message visible only to one user in a channel. Teams has no visibility flag — all bot messages are visible to everyone. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `refresh.userIds` on `Action.Execute` | Slack → Teams | Card shows different content per user. Covers ~80% of cases. Max 60 user IDs per card. | 4–8 hrs | +| Route to 1:1 chat | Slack → Teams | Send private content to user's personal bot chat via proactive messaging. Different UX but reliable. | 2–4 hrs | +| Build `sendEphemeral()` helper | Slack → Teams | Wrapper that auto-detects context and picks the best strategy. Worth it if many handlers use ephemeral. | 8–12 hrs | +| Drop ephemeral behavior | Slack → Teams | Show messages to everyone. Simplest but may expose private data. | 0 hrs | +| **Native `chat.postEphemeral()`** | **Teams → Slack** | **Direct API call. No gap in this direction.** | **0 hrs** | + +--- + +### R2. Custom Emoji Reactions + +**Slack has it. Teams does not.** + +Slack supports unlimited custom emoji as reactions. Teams supports exactly 6 fixed reactions: like, heart, laugh, surprised, sad, angry. Bots that use reactions as workflow signals (`:white_check_mark:` = approved) cannot map to Teams. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Adaptive Card buttons | Slack → Teams | Replace reaction workflows with `Action.Submit` buttons (e.g., "Approve" / "Reject"). Better audit trail. | 4–8 hrs | +| Map to 6 fixed reactions | Slack → Teams | Map most important reactions to like/heart/laugh/surprised/sad/angry. Lossy — only works with ≤6 reactions. | 2–4 hrs | +| **Native emoji reactions** | **Teams → Slack** | **Direct mapping. Slack supports unlimited custom emoji.** | **0 hrs** | + +--- + +### R3. Modal Cancel Notification (`viewClosed`) + +**Slack has it. Teams does not.** + +Slack fires `view_closed` when a user dismisses a modal (with `notify_on_close: true`). Teams sends no notification when a dialog is dismissed — the bot never knows the user cancelled. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Timeout + explicit Cancel button | Slack → Teams | Add a "Cancel" button inside the dialog. Implement 5-min TTL for cleanup of stale locks/state. | 4–8 hrs | +| Accept stale state | Slack → Teams | Drop cancel cleanup. Accept that some locks may persist until TTL. | 0 hrs | +| **Native `notify_on_close: true`** | **Teams → Slack** | **Set `notify_on_close: true` in `views.open()`. Native support.** | **0 hrs** | + +--- + +### R4. Mid-Form Dynamic Updates + +**Slack has it. Teams does not.** + +Slack modals support `dispatch_action: true` on inputs, which fires `block_actions` events while the modal is open. The bot can then call `views.update()` to change the modal dynamically (e.g., show/hide fields based on a dropdown selection). Teams dialogs have no equivalent — Adaptive Card inputs don't fire events until the form is submitted. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Multi-step dialogs | Slack → Teams | Split dependent fields across dialog steps. Step 1 collects the trigger value; step 2 shows dependent fields. | 8–16 hrs | +| `Action.ToggleVisibility` | Slack → Teams | Show/hide elements client-side. Works for simple show/hide but cannot fetch server data. | 2–4 hrs | +| Web-based task module | Slack → Teams | Embed a full web form in an iframe with real-time interactivity. Full control but much more effort. | 16–24 hrs | +| **Native `block_actions` + `views.update()`** | **Teams → Slack** | **Set `dispatch_action: true` on input elements. Handle `block_actions` and call `views.update()`.** | **2–4 hrs** | + +--- + +### R5. Server-Side Field Validation with Inline Errors + +**Slack has it. Teams does not.** + +Slack's `view_submission` handler can return `response_action: "errors"` with a map of `{ block_id: "error message" }` to show inline validation errors without closing the modal. Teams dialogs close on submit — there is no way to keep the dialog open with error messages. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Re-open dialog with errors | Slack → Teams | On validation failure, return a new dialog card pre-populated with the user's data and error messages in field labels. | 4–8 hrs | +| Client-side validation only | Slack → Teams | Use Adaptive Card `isRequired`, `regex`, `maxLength`, `min`/`max`. Covers simple cases but not async checks (e.g., "username taken"). | 1–2 hrs | +| **Native `response_action: "errors"`** | **Teams → Slack** | **Return `{ response_action: "errors", errors: { block_id: "msg" } }` from `view_submission` handler.** | **0 hrs** | + +--- + +### R6. Dialog / Modal Stacking + +**Slack has it. Teams does not.** + +Slack supports `views.push()` to stack up to 3 modals. The user can navigate back by dismissing the top modal. Teams dialogs do not stack — opening a new dialog replaces the current one. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Single dialog with step routing | Slack → Teams | One dialog with internal step state. Submit handler checks step number and returns the next step's card. Add a "Back" button that decrements the step. | 8–16 hrs | +| Build `StepDialog` helper | Slack → Teams | Reusable class managing step state, forward/back navigation. Worth it if 3+ wizard flows exist. | 16–24 hrs | +| Sequential separate dialogs | Slack → Teams | Close current dialog, open next. No back navigation. Degraded UX. | 4–8 hrs | +| **Native `views.push()`** | **Teams → Slack** | **Call `views.push()` from within a `view_submission` or `block_actions` handler. Up to 3 levels.** | **0 hrs** | + +--- + +### R7. Scheduled Message API + +**Slack has it. Teams does not.** + +Slack provides `chat.scheduleMessage()` and `chat.deleteScheduledMessage()` as first-class APIs. Teams has no server-side scheduling — the bot must build its own. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Azure Functions timer + Cosmos DB | Slack → Teams | Store message + target time in DB. Timer function polls every minute and sends via proactive messaging. | 16–24 hrs | +| Azure Queue visibility timeout | Slack → Teams | Enqueue with `visibilityTimeout` set to the delay. Queue trigger fires at the right time. 7-day max. | 8–12 hrs | +| Azure Service Bus scheduled messages | Slack → Teams | `scheduleMessages(msg, scheduledTime)`. Exact-time delivery, native cancellation. Best for high volume. | 12–16 hrs | +| Power Automate | Slack → Teams | "Delay until" action in a flow. No code but requires license for custom connectors. | 8–12 hrs | +| In-process timer (dev only) | Slack → Teams | `setTimeout` / `node-cron`. Not durable — lost on restart. | 2–4 hrs | +| **Native `chat.scheduleMessage()`** | **Teams → Slack** | **Direct API call with `post_at` Unix timestamp. Native cancellation via `deleteScheduledMessage()`.** | **0 hrs** | + +--- + +### R8. Channel Archive + +**Slack has it. Teams does not.** + +Slack's `conversations.archive()` archives a channel — it becomes read-only and hidden from the channel list. Teams can only archive an entire Team, not individual channels. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Rename with `[ARCHIVED]` prefix | Slack → Teams | Rename channel, update description to "Archived on {date}". Non-destructive. Cosmetic only. | 4–8 hrs | +| Rename + remove all members | Slack → Teams | Rename + kick everyone. Stronger enforcement but destructive and hard to undo. | 8–12 hrs | +| Team-level archive | Slack → Teams | Archive the entire Team via Graph. Only works if the channel has a dedicated Team. | 2–4 hrs | +| **Native `conversations.archive()`** | **Teams → Slack** | **Direct API call. Reversible via `conversations.unarchive()`.** | **0 hrs** | + +--- + +### R9. Retroactive Link Unfurling + +**Slack has it. Teams does not.** + +Slack unfurls links in existing messages (edited to add a link, or links posted before the bot was installed). Teams only unfurls links in new messages — editing a message to add a link does not trigger unfurling. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| **Accept the limitation (Recommended)** | Slack → Teams | No workaround exists. New message unfurling works fine. | 0 hrs | +| Manual preview command | Slack → Teams | Bot command where users paste a URL to get a preview card. Niche use case. | 4–8 hrs | +| **Native retroactive unfurling** | **Teams → Slack** | **Slack unfurls retroactively by default. No issue.** | **0 hrs** | + +--- + +### R10. Firewall-Friendly Transport (Socket Mode) + +**Slack has it. Teams does not.** + +Slack's Socket Mode uses an outbound WebSocket — no inbound ports needed. The bot can run behind any firewall. Teams requires a public HTTPS endpoint for inbound webhooks. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Deploy to Azure | Slack → Teams | Host in App Service / Functions / Container Apps. Use Dev Tunnels for local dev. Standard cloud deployment. | 4–8 hrs | +| Azure Relay | Slack → Teams | Hybrid connection for strict on-premises firewalls that cannot expose any public endpoint. Adds latency. | 8–16 hrs | +| **Native Socket Mode** | **Teams → Slack** | **Set `socketMode: true` with `appToken`. Outbound WebSocket, zero inbound ports.** | **1–2 hrs** | + +--- + +## RED Gap Workarounds + +Detailed implementation patterns for every RED gap. These are the recommended approaches — pick the one that fits your bot's needs. + +--- + +### R1 Workaround: Ephemeral via `refresh.userIds` + +The best general-purpose workaround. An `Action.Execute` card with `refresh.userIds` shows personalized content to specific users while showing a default card to everyone else. + +```typescript +// Teams: per-user card content (replaces chat.postEphemeral) +const card = { + type: "AdaptiveCard", + version: "1.4", + refresh: { + action: { + type: "Action.Execute", + verb: "personalView", + data: { requestId: "123" }, + }, + userIds: [actingUserId], // max 60 IDs + }, + body: [ + { type: "TextBlock", text: "A request was submitted." }, // everyone sees this + ], +}; + +// When the specified user views the card, Teams invokes the bot: +app.on("card.action", async (ctx) => { + if (ctx.activity.value?.action?.verb === "personalView") { + // Return a personalized card only this user sees + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { type: "TextBlock", text: "Your request #123 was approved.", weight: "Bolder" }, + { type: "TextBlock", text: "Only you can see these details." }, + ], + }, + }; + } +}); +``` + +**When this doesn't work:** More than 60 users need per-user views, or the content is plain text (not a card). Fall back to sending a proactive message in the user's 1:1 bot chat. + +**Reverse (Teams → Slack):** Use `chat.postEphemeral({ channel, user, text })` directly. Native support. + +--- + +### R2 Workaround: Reactions → Adaptive Card Buttons + +Replace emoji-reaction workflows with explicit card buttons. This actually improves auditability — button clicks are tracked, emoji reactions are not. + +```typescript +// Before (Slack): reaction-based approval +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "white_check_mark") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Approved by <@${event.user}>`, + thread_ts: event.item.ts, + }); + } +}); + +// After (Teams): button-based approval +const approvalCard = { + type: "AdaptiveCard", version: "1.5", + body: [{ type: "TextBlock", text: "Request #42 needs approval" }], + actions: [ + { type: "Action.Submit", title: "Approve", style: "positive", + data: { action: "approve", requestId: "42" } }, + { type: "Action.Submit", title: "Reject", style: "destructive", + data: { action: "reject", requestId: "42" } }, + ], +}; +``` + +**When reactions are decorative** (not workflow signals): map to the 6 fixed Teams reactions. Only viable if you use ≤6 distinct reactions. + +**Reverse (Teams → Slack):** Map `Action.Submit` buttons to emoji reactions via `reactions.add`, or keep as Slack buttons (usually better UX anyway). + +--- + +### R3 Workaround: Cancel Detection via TTL + Explicit Button + +Since Teams sends no notification when a dialog is dismissed, combine two strategies: + +```typescript +// 1. Add an explicit Cancel button inside the dialog card +const dialogCard = { + type: "AdaptiveCard", version: "1.5", + body: [/* form fields */], + actions: [ + { type: "Action.Submit", title: "Submit", data: { action: "submit_form" } }, + { type: "Action.Submit", title: "Cancel", data: { action: "cancel_form", lockId: "abc" } }, + ], +}; + +// 2. Handle explicit cancel +app.on("dialog.submit", async ({ activity, send }) => { + const data = activity.value?.data; + if (data?.action === "cancel_form") { + await releaseLock(data.lockId); + return { status: 200, body: { task: { type: "message", value: "Cancelled." } } }; + } + // ... handle submit ... +}); + +// 3. TTL-based cleanup for users who close via the X button +setInterval(async () => { + const staleLocks = await getLocksOlderThan(5 * 60_000); // 5 min + for (const lock of staleLocks) await releaseLock(lock.id); +}, 60_000); +``` + +**Reverse (Teams → Slack):** Use `notify_on_close: true` in `views.open()` and handle `view_closed` callback. + +--- + +### R4 Workaround: Mid-Form Updates via Multi-Step Dialogs + +Split dependent fields across dialog steps. Step 1 collects the value that drives the dynamic behavior; step 2 renders the dependent fields. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + + if (data?.step === 1) { + // User selected a category — return step 2 with dependent fields + const subcategories = await getSubcategories(data.category); + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Step 2 of 2", + card: buildStep2Card(data.category, subcategories), + }, + }, + }, + }; + } + + if (data?.step === 2) { + // Final submission + await processForm(data); + return { status: 200, body: { task: { type: "message", value: "Done!" } } }; + } +}); +``` + +**For simple show/hide** (no server data needed): use `Action.ToggleVisibility` to show/hide card elements client-side. This works for "show advanced options" toggles but cannot populate options from an API. + +**Reverse (Teams → Slack):** Use `dispatch_action: true` on inputs + `views.update()` in the `block_actions` handler. Native support for real-time form updates. + +--- + +### R5 Workaround: Server Validation via Dialog Re-render + +On validation failure, return a `continue` response with the same form, pre-populated with the user's values, plus error messages as colored `TextBlock` elements. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + const errors: string[] = []; + + if (!data?.email?.includes("@")) errors.push("Invalid email address"); + if ((data?.name?.length ?? 0) < 2) errors.push("Name must be at least 2 characters"); + + if (errors.length > 0) { + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Fix Errors", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", version: "1.5", + body: [ + // Error banner + ...errors.map(e => ({ + type: "TextBlock", text: e, color: "Attention", weight: "Bolder", + })), + // Re-populate form with user's previous values + { type: "Input.Text", id: "name", label: "Name", value: data.name ?? "" }, + { type: "Input.Text", id: "email", label: "Email", value: data.email ?? "" }, + ], + actions: [{ type: "Action.Submit", title: "Submit", data: { action: "register" } }], + }, + }, + }, + }, + }, + }; + } + + // Validation passed + await processRegistration(data); + return { status: 200, body: { task: { type: "message", value: "Registered!" } } }; +}); +``` + +**Combine with client-side validation** for the best UX: add `isRequired`, `regex`, and `errorMessage` to catch obvious errors before the server round-trip. + +**Reverse (Teams → Slack):** Use `response_action: "errors"` with `{ block_id: "error message" }` natively. + +--- + +### R6 Workaround: Modal Stacking via Step Routing + +Simulate `views.push` with a single dialog that routes by step number. Include a "Back" button that decrements the step. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + const step = data?.step ?? 1; + + if (data?.action === "back") { + return continueDialog(buildStepCard(step - 1, data)); + } + + if (step < 3) { + return continueDialog(buildStepCard(step + 1, data)); + } + + // Final step — process all collected data + await processWizard(data); + return { status: 200, body: { task: { type: "message", value: "Complete!" } } }; +}); + +function continueDialog(card: object) { + return { + status: 200, + body: { task: { type: "continue", value: { title: `Step ${(card as any).step}`, card } } }, + }; +} + +function buildStepCard(step: number, previousData: Record): object { + // Each step card embeds ALL previous data in Action.Submit.data + // so nothing is lost between steps + return { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", version: "1.5", + body: [/* step-specific fields */], + actions: [ + ...(step > 1 ? [{ type: "Action.Submit", title: "Back", + data: { ...previousData, step, action: "back" } }] : []), + { type: "Action.Submit", title: step === 3 ? "Finish" : "Next", + data: { ...previousData, step, action: "next" } }, + ], + }, + step, + }; +} +``` + +**Key principle:** Every step's `Action.Submit.data` must carry forward ALL data from previous steps, since there's no persistent modal state like Slack's `private_metadata`. + +**Reverse (Teams → Slack):** Use `views.push()` natively — up to 3 levels of stacking with built-in "X to go back" behavior. + +--- + +### R7 Workaround: Scheduling via Azure Service Bus + +The most production-ready approach. Azure Service Bus supports exact-time delivery and native cancellation. + +```typescript +import { ServiceBusClient } from "@azure/service-bus"; + +const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION!); +const sender = sbClient.createSender("scheduled-messages"); + +// Schedule a message +async function scheduleMessage( + conversationId: string, text: string, sendAt: Date +): Promise { + const [sequenceNumber] = await sender.scheduleMessages( + { body: { conversationId, text } }, + sendAt + ); + return sequenceNumber; // store this for cancellation +} + +// Cancel a scheduled message +async function cancelScheduled(sequenceNumber: Long): Promise { + await sender.cancelScheduledMessages(sequenceNumber); +} + +// Receiver (runs as a separate process or Azure Function) +const receiver = sbClient.createReceiver("scheduled-messages"); +receiver.subscribe({ + processMessage: async (msg) => { + const { conversationId, text } = msg.body; + await teamsApp.send(conversationId, text); + }, + processError: async (err) => console.error(err), +}); +``` + +**For simpler needs:** Azure Queue with `visibilityTimeout` (max 7 days) or Azure Functions timer + Cosmos DB (poll every minute). + +**Reverse (Teams → Slack):** Use `chat.scheduleMessage({ channel, text, post_at })` natively. + +--- + +### R8 Workaround: Channel Archive via Rename + Description + +The most widely used workaround. Cosmetic-only — doesn't actually prevent new messages. + +```typescript +async function archiveChannel( + graph: Client, teamId: string, channelId: string +): Promise { + const channel = await graph.api(`/teams/${teamId}/channels/${channelId}`).get(); + + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + displayName: `[ARCHIVED] ${channel.displayName}`.substring(0, 50), + description: `Archived on ${new Date().toISOString()}. Original: ${channel.description ?? ""}`, + }); +} +``` + +**For stronger enforcement:** After renaming, remove all non-owner members. This is destructive (members must be re-invited to undo) but prevents new messages. + +**Reverse (Teams → Slack):** Use `conversations.archive()` natively. Reversible via `conversations.unarchive()`. + +--- + +### R9 Workaround: Retroactive Unfurling + +**No workaround exists.** Teams only unfurls links in new messages. Accept this limitation — it affects a small percentage of use cases (links in edited messages or messages sent before the bot was installed). + +If critical, build a `/preview ` bot command that returns a card preview on demand. + +--- + +### R10 Workaround: Firewall Transport + +**Deploy to a cloud provider.** This is the standard path for any Teams bot. For local development, use Dev Tunnels (built into VS Code) or ngrok. + +For strict on-premises environments that truly cannot expose any endpoint, Azure Relay provides a hybrid connection where the bot connects outbound to Azure, and Azure proxies inbound Teams traffic through that connection. This adds 10–50ms latency but requires zero inbound firewall rules. + +**Reverse (Teams → Slack):** Enable Socket Mode with `socketMode: true` and `appToken`. Zero inbound ports, zero tunneling. + +--- + +## YELLOW Gaps — Equivalent Exists but Requires Design Decisions + +These features have functional equivalents on the other platform, but the mapping is not 1:1 and requires choosing an approach. + +--- + +### Y1. Slash Commands + +**Slack has native `/command`. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Text pattern matching | Slack → Teams | Detect command-like text in `app.on("message")`. Accept `weather` and `/weather`. | 2–4 hrs | +| Manifest bot commands | Slack → Teams | Add `commands[]` to manifest for Teams command menu. Not `/` prefix but discoverable. | 1–2 hrs | +| Message extension | Slack → Teams | `composeExtensions` for richer command UX with search results or task modules. | 8–12 hrs | +| **Native `app.command()`** | **Teams → Slack** | **Register via `app.command("/cmd", handler)`. Add `ack()` call. Configure in Slack app dashboard.** | **2–4 hrs** | + +--- + +### Y2. Thread Broadcast (`reply_broadcast`) + +**Slack has it as a single call. Teams requires two.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Two API calls | Slack → Teams | Call `reply()` (thread) + `send()` (channel) separately. | 1–2 hrs | +| `replyWithBroadcast()` wrapper | Slack → Teams | Convenience method that calls both internally. | 2–4 hrs | +| **Native `reply_broadcast: true`** | **Teams → Slack** | **Single `say()` call with `reply_broadcast: true`.** | **0 hrs** | + +--- + +### Y3. Thread Discovery + +**Slack has `conversations.replies()`. Teams uses Graph API.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Graph API direct | Slack → Teams | `GET /teams/{teamId}/channels/{channelId}/messages/{messageId}/replies`. Requires `ChannelMessage.Read.All`. | 4–8 hrs | +| `getThreadReplies()` helper | Slack → Teams | Wrapper encapsulating Graph client setup, auth, and pagination. | 8–12 hrs | +| **Native `conversations.replies()`** | **Teams → Slack** | **Direct API call with thread `ts`.** | **0 hrs** | + +--- + +### Y4/5/6. File Upload + +**Slack: one call. Teams: 3-step consent flow.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `sendFile()` helper | Slack → Teams | Unified wrapper: auto-detects personal/channel, routes to OneDrive/SharePoint, chunks >4 MB. | 24–40 hrs | +| Manual FileConsentCard | Slack → Teams | Implement 3-step consent flow directly. Verbose and error-prone. | 16–24 hrs | +| **Native `files.uploadV2()`** | **Teams → Slack** | **Single API call. No consent step.** | **1–2 hrs** | + +--- + +### Y7. Link Unfurling Deadline + +**Slack: 30-minute async. Teams: 5-second sync.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Cache-first with prefetch | Slack → Teams | Cache middleware wraps handler. Pre-populate for known URLs. Without this, slow unfurls silently fail. | 12–16 hrs | +| Synchronous handler only | Slack → Teams | Direct handler. Only viable for fast data sources (<5 seconds). | 4–8 hrs | +| **Native async `chat.unfurl()`** | **Teams → Slack** | **Handle `link_shared` event. Respond within 30 minutes via `chat.unfurl()`.** | **2–4 hrs** | + +--- + +### Y8. Reminders + +**Slack has `reminders.add()`. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Piggyback on scheduler (R7) | Slack → Teams | Reuse scheduled message infrastructure. `setReminder()` stores + sends to 1:1 chat at target time. | 4–8 hrs (if scheduler exists) | +| Power Automate + Planner | Slack → Teams | Create Planner tasks with due-date notifications. | 8–12 hrs | +| **Native `reminders.add()`** | **Teams → Slack** | **Direct API call. Platform-managed delivery.** | **0 hrs** | + +--- + +### Y9. Dynamic Select Menus (Server-Side Typeahead) + +**Slack has `external_data_source` + `block_suggestion`. Teams does not.** + +Slack's `app.options()` handler receives keystrokes and returns filtered results from the server. Teams' `Input.ChoiceSet` is client-side only. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Pre-populated `Input.ChoiceSet` | Slack → Teams | Load all options at dialog open. Client-side filtering via `style: "filtered"`. Works up to ~500 items. | 2–4 hrs | +| Two-step dialog | Slack → Teams | Step 1: text input for search. Step 2: filtered results as `ChoiceSet`. Works for any dataset size. | 8–12 hrs | +| Web-based task module | Slack → Teams | Embed a web view with search-as-you-type. Full control. High effort. | 16–24 hrs | +| **Native `block_suggestion`** | **Teams → Slack** | **Set `external_data_source: true` on select. Handle `app.options()` for server-side filtering.** | **2–4 hrs** | + +--- + +### Y10. App Home + +**Slack has `app_home_opened` + `views.publish()`. Teams uses tabs.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `tab.fetch` handler | Slack → Teams | Personal tab returns Adaptive Card on every open. Closest to `app_home_opened`. | 4–8 hrs | +| Welcome card on install | Slack → Teams | Send card to 1:1 chat on `install.add`. Simple but fires once. | 1–2 hrs | +| Static web tab | Slack → Teams | Full web page in iframe. Richer but needs hosting + Teams JS SDK. | 8–16 hrs | +| **Native `views.publish()`** | **Teams → Slack** | **Listen for `app_home_opened` event. Call `views.publish()` with Block Kit.** | **2–4 hrs** | + +--- + +### Y11. View Hash (Race Condition Protection) + +**Slack has `view_hash`. Teams does not.** + +Slack's `views.update()` accepts a `view_hash` parameter. If the view has changed since the hash was captured, the update is rejected. This prevents race conditions. Teams has no equivalent. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Manual `_version` field | Slack → Teams | Inject version counter into `Action.Submit.data`. Reject updates where the submitted version doesn't match the stored version. | 2–4 hrs | +| Card versioning middleware | Slack → Teams | SDK plugin auto-injecting and checking version counters on every card send/receive. | 4–8 hrs | +| **Native `view_hash`** | **Teams → Slack** | **Pass `view_hash` from the previous `views.open()` / `views.update()` response.** | **0 hrs** | + +--- + +### Y12. Global Shortcuts + +**Slack has `app.shortcut()` (global). Teams uses compose extensions.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Compose extension | Slack → Teams | `composeExtensions` with `context: ["compose", "commandBox"]`. Always opens task module. | 8–12 hrs | +| Minimal-dismiss pattern | Slack → Teams | Task module returns tiny "Done" card for fire-and-forget actions. | 4–8 hrs | +| Bot command | Slack → Teams | Replace shortcut with typed command. Simpler but less discoverable. | 2–4 hrs | +| **Native `app.shortcut()`** | **Teams → Slack** | **Register global shortcut callback. Can fire-and-forget (ack + background work).** | **2–4 hrs** | + +--- + +### Y13. Message Shortcuts + +**Slack has `message_shortcut`. Teams uses action-based message extensions.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Action message extension | Slack → Teams | `composeExtensions` command with `context: ["message"]`. Message payload in `activity.value.messagePayload`. | 4–8 hrs | +| **Native `message_shortcut`** | **Teams → Slack** | **Register `app.shortcut()` with type `message_shortcut`. Message in `shortcut.message`.** | **2–4 hrs** | + +--- + +### Y14. Confirmation Dialogs on Buttons + +**Slack has native `confirm` object. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `Action.ShowCard` inline | Slack → Teams | Inline expand with "Are you sure?" + Yes/No buttons. Native Adaptive Card. | 2–4 hrs | +| Task module confirm | Slack → Teams | Small dialog popup. More prominent, closer to Slack UX. | 4–6 hrs | +| `confirmAction()` helper | Slack → Teams | Template function generating confirm cards. Reusable. | 4–8 hrs | +| **Native `confirm` object** | **Teams → Slack** | **Add `confirm` object to button element. Platform-rendered popup.** | **0 hrs** | + +--- + +### Y15. Unfurl Domain Wildcards + +**Slack supports `*.example.com`. Teams requires exact domain listing.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Manual enumeration | Slack → Teams | List every subdomain in manifest `domains[]`. Fine for <10. | 1–2 hrs | +| Manifest generator script | Slack → Teams | Script reads subdomain list from config and generates manifest array. | 4–8 hrs | +| **Native wildcard support** | **Teams → Slack** | **Wildcards work out of the box.** | **0 hrs** | + +--- + +### Y16. All Channel Messages Without @Mention + +**Slack gets them by default. Teams requires RSC permission.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| RSC permission | Slack → Teams | Add `ChannelMessage.Read.Group` to manifest `webApplicationInfo.applicationPermissions`. Config only. | 1–2 hrs | +| Require @mention | Slack → Teams | Change UX to require @mention. Simplifies permissions but changes behavior. | 0 hrs | +| **Default behavior** | **Teams → Slack** | **Slack bots receive all messages in channels they're added to. No config needed.** | **0 hrs** | + +--- + +### Y17. Built-in Retry / Resilience + +**Slack Bolt has `retryConfig`. Teams SDK has no built-in retry.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Build `RetryPlugin` | Slack → Teams | Plugin with exponential backoff, jitter, circuit breaker. | 12–16 hrs | +| Manual retry wrapper | Slack → Teams | Hand-roll backoff around outbound calls. Simpler but easy to get wrong. | 4–8 hrs | +| **Native Bolt `retryConfig`** | **Teams → Slack** | **Configure in `App` constructor. Built-in exponential backoff.** | **0 hrs** | + +--- + +### Y18. Workflow Builder + +**Slack has it (free). Teams uses Power Automate (licensed).** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Bot-driven orchestration | Slack → Teams | State machine + Adaptive Card buttons + persistent storage. No license dependency. | 16–40 hrs | +| Power Automate rebuild | Slack → Teams | Rebuild in Power Automate. Custom steps need Premium license. | 24–80 hrs | +| Teams Workflows app | Slack → Teams | Simplified UI for basic automations (free). Limited scenarios. | 4–8 hrs | +| Hybrid | Slack → Teams | Simple flows → Power Automate, complex → bot-driven. | Varies | +| **Native Workflow Builder** | **Teams → Slack** | **Rebuild in Slack Workflow Builder. Free, no license.** | **8–16 hrs** | + +--- + +### Y19. App Distribution + +**Both platforms have app stores, but packaging and review differ.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Org app catalog | Slack → Teams | Publish to organization catalog via Teams Admin Center. Requires admin approval. | 2–4 hrs | +| Sideloading | Slack → Teams | ZIP manifest + icons. Upload via Teams client. May be disabled by admin. | 1–2 hrs | +| Partner Center (public) | Slack → Teams | Submit to Teams App Store. 1–2 week review. Requires Partner Network account. | 8–16 hrs | +| **Slack App Directory** | **Teams → Slack** | **Submit via api.slack.com. Hours-to-days review. Implement `InstallProvider` for OAuth install flow.** | **4–8 hrs** | + +--- + +--- + +## YELLOW Gap Best Practices + +Recommended approaches for every YELLOW gap. These are the patterns that produce the best cross-platform UX with the least effort. + +--- + +### Y1. Slash Commands — Best Practice + +**Use text pattern matching + manifest bot commands together.** + +Register commands in the Teams manifest for discoverability (users see them in the command menu), AND detect them via text pattern matching as a fallback. Accept both `/weather` and `weather` so users migrating from Slack don't have to retrain muscle memory. + +```typescript +// Teams: detect both patterns +app.message(/^\/?weather$/i, async (ctx) => { + const response = await handleWeather(); + await ctx.send(response); +}); +``` + +In the Teams manifest: +```json +{ "commands": [{ "title": "weather", "description": "Check the weather" }] } +``` + +**Don't:** Create a message extension for every slash command. Reserve extensions for commands that benefit from rich search results or task module UI. + +--- + +### Y2. Thread Broadcast — Best Practice + +**Write a one-line helper that makes both calls.** Don't over-engineer this. + +```typescript +async function replyWithBroadcast(ctx: any, text: string): Promise { + await ctx.reply(text); + await ctx.send(text); +} +``` + +**Don't:** Try to batch these into a single API call — Teams doesn't support it. Two calls is the correct pattern. + +--- + +### Y3. Thread Discovery — Best Practice + +**Use Graph API directly with the `ctx.appGraph` client.** Don't build a wrapper unless you need pagination across multiple threads. + +```typescript +const replies = await ctx.appGraph + .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`) + .top(50) + .get(); +``` + +**Watch out for:** `ChannelMessage.Read.All` is an application permission requiring admin consent. If you only need thread replies in the bot's own conversations, you may be able to use delegated permissions instead. + +--- + +### Y4/5/6. File Upload — Best Practice + +**Build the `sendFile()` helper.** The manual FileConsentCard flow is a 30-line footgun that's easy to get wrong. A helper that auto-detects personal vs. channel context and handles chunking for large files pays for itself after the second use. + +**Key decisions:** +- Personal chat → FileConsentCard flow (requires `supportsFiles: true` in manifest) +- Channel → Direct Graph API upload to SharePoint (no consent card) +- Files >4 MB → Graph resumable upload session with 320 KB–60 MB chunks + +**Don't:** Store pending file buffers in memory for long periods. Upload promptly or stream to a temporary blob. + +--- + +### Y7. Link Unfurling — Best Practice + +**Always use a cache layer.** The 5-second Teams deadline makes this non-optional. Cache aggressively: + +1. On first unfurl, fetch and cache the preview data +2. Set a reasonable TTL (5–60 minutes depending on data freshness needs) +3. For known high-traffic URLs, pre-populate the cache on startup + +```typescript +const cache = new Map(); + +app.on("message.ext.query-link", async ({ activity }) => { + const url = activity.value?.url; + const cached = cache.get(url); + if (cached && cached.expires > Date.now()) { + return buildUnfurlResponse(cached.data); + } + const data = await fetchPreviewData(url); // must complete in <4 seconds + cache.set(url, { data, expires: Date.now() + 300_000 }); // 5 min TTL + return buildUnfurlResponse(data); +}); +``` + +**Don't:** Make multiple API calls in the unfurl handler. Pre-fetch or batch data sources. + +--- + +### Y8. Reminders — Best Practice + +**Piggyback on whatever scheduling infrastructure you built for R7.** Don't create a separate system. A reminder is just a scheduled message sent to a 1:1 conversation. + +```typescript +async function setReminder(userId: string, text: string, when: Date): Promise { + const conversationId = await get1to1ConversationId(userId); + await scheduleMessage(conversationId, `Reminder: ${text}`, when); +} +``` + +**Don't:** Use Power Automate + Planner for bot reminders — it adds an external dependency and licensing complexity. Keep it in the bot. + +--- + +### Y9. Dynamic Select Menus — Best Practice + +**Pre-populate with `Input.ChoiceSet` `style: "filtered"` for datasets under 500 items.** This covers the vast majority of cases (user lists, category selects, project dropdowns). + +```json +{ + "type": "Input.ChoiceSet", + "id": "user_select", + "label": "Assign to", + "style": "filtered", + "choices": [ + { "title": "Alice Smith", "value": "alice@company.com" }, + { "title": "Bob Jones", "value": "bob@company.com" } + ] +} +``` + +**For datasets over 500 items:** Use a two-step dialog. Step 1 is a text input for search. The submit handler queries the server and returns step 2 with filtered results as a `ChoiceSet`. + +**Don't:** Build a web-based task module just for a searchable dropdown. The effort (16–24 hrs) rarely justifies the marginal UX improvement over two-step. + +--- + +### Y10. App Home — Best Practice + +**Use `tab.fetch` to return an Adaptive Card.** It fires on every tab open (like `app_home_opened`) and supports `tab.submit` for interactions within the tab. + +```typescript +app.on("tab.fetch", async (ctx) => { + const userData = await getUserDashboard(ctx.activity.from?.aadObjectId ?? ""); + return { + status: 200, + body: { + tab: { + type: "continue", + value: { cards: [{ card: buildDashboardCard(userData) }] }, + }, + }, + }; +}); +``` + +**Don't:** Use a static web tab unless you need rich interactivity beyond what Adaptive Cards can provide (charts, real-time updates, complex navigation). Web tabs require hosting, CORS configuration, and the Teams JS SDK. + +--- + +### Y11. View Hash — Best Practice + +**Inject a `_version` counter into every card's `Action.Submit.data`.** Increment on every update. Reject submissions where the version doesn't match. + +```typescript +let cardVersion = 0; + +function buildCard(data: any): object { + cardVersion++; + return { + type: "AdaptiveCard", version: "1.5", + body: [/* ... */], + actions: [{ + type: "Action.Submit", title: "Update", + data: { ...data, _version: cardVersion }, + }], + }; +} + +app.on("card.action", async (ctx) => { + const submitted = ctx.activity.value?.action?.data; + if (submitted?._version !== cardVersion) { + await ctx.send("This card is outdated. Please use the latest version."); + return; + } + // Process the update... +}); +``` + +**Don't:** Skip version checking for low-traffic bots — race conditions happen even with single users (fast double-clicks, multiple tabs). + +--- + +### Y12. Global Shortcuts — Best Practice + +**Use compose extensions for actions that open a form.** For fire-and-forget actions (no UI), use the minimal-dismiss pattern: return a tiny "Done" card that auto-closes. + +```json +{ + "composeExtensions": [{ + "commands": [{ + "id": "quickAction", + "type": "action", + "title": "Quick Action", + "context": ["compose", "commandBox"], + "fetchTask": true + }] + }] +} +``` + +**Don't:** Replace every shortcut with a bot command. Commands are less discoverable than compose extensions, which appear in the Teams UI with icons and descriptions. + +--- + +### Y13. Message Shortcuts — Best Practice + +**Use action-based message extensions with `context: ["message"]`.** This is the closest 1:1 mapping to Slack's message shortcuts. + +Access the original message via `activity.value.messagePayload` — it contains the message text, sender, and timestamp. + +**Don't:** Forget to add `fetchTask: true` in the manifest command. Without it, the extension silently does nothing when clicked. + +--- + +### Y14. Confirmation Dialogs — Best Practice + +**Use `Action.ShowCard` for inline confirmation.** It expands inline without leaving the current context — closest to Slack's native `confirm` popup. + +```json +{ + "type": "Action.ShowCard", + "title": "Delete", + "card": { + "type": "AdaptiveCard", + "body": [{ "type": "TextBlock", "text": "Are you sure you want to delete this?", "weight": "Bolder" }], + "actions": [ + { "type": "Action.Submit", "title": "Yes, delete", "style": "destructive", + "data": { "action": "confirm_delete", "itemId": "42" } }, + { "type": "Action.Submit", "title": "Cancel", + "data": { "action": "cancel_delete" } } + ] + } +} +``` + +**Don't:** Open a full task module dialog for a simple yes/no confirmation. It's too heavy for the interaction. + +--- + +### Y15. Unfurl Domain Wildcards — Best Practice + +**Enumerate domains in the manifest.** For fewer than 10 subdomains, list them manually. For more, write a build-time script that reads your subdomain list and generates the manifest array. + +```json +{ + "composeExtensions": [{ + "messageHandlers": [{ + "type": "link", + "value": { + "domains": [ + "app.example.com", + "docs.example.com", + "api.example.com" + ] + } + }] + }] +} +``` + +**Don't:** Try to register a single wildcard domain — Teams will reject it silently. + +--- + +### Y16. All Channel Messages — Best Practice + +**Add the RSC permission to the manifest.** It's config-only, no code change, and matches Slack's default behavior. + +```json +{ + "webApplicationInfo": { + "id": "{{CLIENT_ID}}", + "resource": "api://{{CLIENT_ID}}" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +Also set `activity.mentions.stripText: true` in the App constructor to remove `bot` text from messages that do include an @mention. + +**Don't:** Change your UX to require @mention unless your bot genuinely shouldn't listen to all messages. + +--- + +### Y17. Built-in Retry — Best Practice + +**Build a retry utility with exponential backoff and jitter.** Apply it to all outbound API calls (Graph, proactive messaging, external services). + +```typescript +async function withRetry(fn: () => Promise, maxRetries = 3): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxRetries) throw err; + const retryAfter = err?.response?.headers?.["retry-after"]; + const baseDelay = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt; + const jitter = Math.random() * 1000; + await new Promise(r => setTimeout(r, baseDelay + jitter)); + } + } + throw new Error("Unreachable"); +} +``` + +**For proactive broadcasts** (sending to hundreds of users): use `p-queue` with concurrency control (e.g., 5 concurrent sends) to stay within Teams' rate limits (~1 msg/sec/conversation). + +**Don't:** Retry without jitter. Without random delay, multiple bot instances retry at the same time and cause a thundering herd. + +--- + +### Y18. Workflow Builder — Best Practice + +**Keep workflow logic in the bot (bot-driven orchestration).** This avoids Power Automate licensing dependencies and keeps everything in one codebase. + +Pattern: state machine with Adaptive Card buttons for user decisions, persistent storage for workflow state, and proactive messaging for notifications. + +**When to use Power Automate instead:** Approval workflows that benefit from the built-in Approvals connector, and simple recurring tasks that business users should manage themselves. + +**Don't:** Build a hybrid system (some flows in Power Automate, some in the bot) unless you have a clear organizational reason. Two systems means two places to debug. + +--- + +### Y19. App Distribution — Best Practice + +**Start with sideloading for dev/test, use org catalog for internal deployment, and Partner Center only for public distribution.** + +Sideloading checklist: +1. `manifest.json` — schema v1.19+, valid `id`, correct `botId` +2. `color.png` — 192x192 full-color icon +3. `outline.png` — 32x32 transparent monochrome outline +4. ZIP all three (no nested folders) +5. Upload via Teams client → Apps → Manage your apps + +**Don't:** Submit to Partner Center (public store) until the bot is fully stable. The 1–2 week review cycle makes iteration slow. Use org catalog for internal users. + +--- + +## Summary: Gap Asymmetry + +Most RED gaps are asymmetric — they only apply in one direction. The pattern is clear: + +| Direction | RED gaps to handle | Why | +|---|---|---| +| **Slack → Teams** | 10 RED gaps | Teams lacks ephemeral, custom reactions, modal stacking, cancel notifications, mid-form updates, field validation, scheduling, channel archive, retroactive unfurl, Socket Mode | +| **Teams → Slack** | 0 RED gaps | Slack has native support for everything Teams offers, plus more | + +This means **adding Slack to a Teams bot is significantly easier** than adding Teams to a Slack bot. A Teams → Slack migration mostly involves mapping concepts 1:1 (Adaptive Cards → Block Kit, `app.on("message")` → `app.message()`, etc.) with few design decisions. A Slack → Teams migration requires redesigning multiple interaction patterns. + +### Effort Estimates by Bot Complexity + +| Profile | Slack Features Used | Slack → Teams Effort | Teams → Slack Effort | +|---|---|---|---| +| **A** — Simple | Messages, basic commands, simple cards | 8–16 hrs | 4–8 hrs | +| **B** — Moderate | A + ephemeral, threads, files, basic interactivity | 40–80 hrs | 8–16 hrs | +| **C** — Complex | B + shortcuts, App Home, unfurling, dynamic selects, modals | 80–160 hrs | 16–32 hrs | +| **D** — Full | C + workflows, scheduling, Socket Mode, stacked modals | 160–300 hrs | 32–48 hrs | diff --git a/skills/microsoft-365-agents-toolkit/docs/files-and-links.md b/skills/microsoft-365-agents-toolkit/docs/files-and-links.md new file mode 100644 index 000000000..4f9bc3a9d --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/files-and-links.md @@ -0,0 +1,85 @@ +# Files & Links + +## File Upload + +| Aspect | Slack | Teams | +|---|---|---| +| Upload API | `files.uploadV2()` — single call | FileConsentCard → user consent → Graph API upload (3-step flow) | +| Large files | Handled automatically | Graph resumable upload sessions for >4 MB | +| Sharing links | `files.sharedPublicURL()` | Graph `createLink()` | +| File events | `file_shared` event | Check `activity.attachments` in message handler | +| Download | `files.info()` → `url_private` with Bearer token | `attachment.content.downloadUrl` (pre-authenticated, short-lived) | +| Manifest config | None | `supportsFiles: true` required | +| Context | Works in channels and DMs | FileConsentCard works in personal chat only; channels use direct Graph upload | + +**Rating:** YELLOW (Slack → Teams), GREEN (Teams → Slack). + +### Impact + +Slack's one-call `files.uploadV2()` becomes a 3-step flow in Teams: send consent card → user approves → upload via Graph API. Missing the `supportsFiles: true` manifest flag causes silent failure. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`sendFile()` helper (Recommended)** | Unified wrapper: auto-detects personal/channel context, routes to OneDrive or SharePoint, handles >4 MB chunking. The manual flow is error-prone. | 24–40 hrs | +| **Manual FileConsentCard** | Implement the 3-step consent flow directly. Works but verbose and easy to get wrong. | 16–24 hrs per upload pattern | + +### Reverse Direction (Teams → Slack) + +Use `files.uploadV2()` directly — much simpler than the Teams consent flow. No consent step needed. + +### File Download + +| Aspect | Slack | Teams | +|---|---|---| +| URL lifetime | Permanent (with valid token) | Pre-authenticated URL expires quickly | +| Auth required | Bearer token in request | URL is pre-authenticated | + +**Mitigation:** For Teams downloads, use the URL immediately or cache the file. Don't store the download URL for later use. + +--- + +## Link Unfurling / Previews + +| Aspect | Slack | Teams | +|---|---|---| +| Event | `link_shared` event (async) | `message.ext.query-link` handler (synchronous) | +| Response deadline | 30 minutes (via `chat.unfurl()`) | **5 seconds** | +| Domain matching | Wildcards supported (`*.example.com`) | **Exact domain only** — must enumerate every subdomain | +| Manifest config | Event subscription in app settings | `composeExtensions[].messageHandlers[].value.domains` | +| Retroactive unfurling | Unfurls links in existing messages | **New messages only** | +| Response format | Attachment with Block Kit | Adaptive Card via `composeExtension` result | + +**Rating:** YELLOW for basic unfurling, RED for retroactive unfurling and wildcard domains. + +### Impact + +The 5-second deadline is the critical difference. Slack allows async unfurling up to 30 minutes later. Teams requires a synchronous response within 5 seconds — any slow data source (API call, database query, rendering) will silently fail. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Cache-first with prefetch (Recommended)** | Cache middleware wraps the handler. Pre-populate cache for known URLs. Without this, slow unfurls silently die. | 12–16 hrs | +| **Synchronous handler only** | Direct handler, must return within 5 seconds. Only viable for fast data sources (in-memory, pre-cached). | 4–8 hrs | + +### Wildcard Domains (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Manual enumeration (Recommended)** | List every subdomain in manifest `domains` array. Fine for <10 subdomains. | 1–2 hrs | +| **Manifest generator script** | Script reads subdomains from config and generates the manifest array. Worth it for 10+ subdomains. | 4–8 hrs | + +### Reverse Direction (Teams → Slack) + +Use `link_shared` event with `chat.unfurl()`. Slack supports wildcards and async responses — both are easier than the Teams model. + +### Retroactive Unfurling + +| Direction | Behavior | +|---|---| +| Slack → Teams | **Platform gap.** Teams only unfurls links in new messages. No workaround exists. Consider a bot command where users paste a URL to get a preview card. | +| Teams → Slack | Slack unfurls links retroactively by default. No issue. | + +**Rating:** RED — no mitigation. Accept the limitation. diff --git a/skills/microsoft-365-agents-toolkit/docs/identity-and-auth.md b/skills/microsoft-365-agents-toolkit/docs/identity-and-auth.md new file mode 100644 index 000000000..d8ab234d2 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/identity-and-auth.md @@ -0,0 +1,107 @@ +# Identity & Auth + +## User IDs + +| Aspect | Slack | Teams | +|---|---|---| +| Format | Prefixed strings: `U...` (user), `C...` (channel), `T...` (team), `B...` (bot) | GUIDs: AAD object IDs, opaque conversation IDs | +| User identity | `message.user` → Slack user ID | `activity.from.id` → AAD object ID | +| Cross-reference | `users.info({ user })` → email, display name | `userGraph.me()` → email, display name | +| Channel identity | `channel_id` (flat namespace) | `conversation.id` (scoped to team) | + +**Rating:** RED — IDs are completely incompatible. No conversion formula exists. + +### Impact + +Any stored data keyed by Slack user/channel IDs (preferences, history, permissions) cannot be directly used with Teams IDs. A mapping layer is required. + +### Mitigation Strategy + +Use **email** as the common identity attribute: + +1. Build a mapping table: `Slack user ID → email → AAD Object ID` +2. Populate from Slack's `users.info()` and Teams' Graph API `users/{id}` +3. Re-key stored data during migration +4. For new dual-platform bots, key data by email from the start + +Effort: 8–16 hrs depending on data volume. + +--- + +## Authentication & Signing + +| Aspect | Slack | Teams | +|---|---|---| +| Request verification | `signingSecret` — HMAC-SHA256 of `v0:{timestamp}:{body}` | Bot Framework JWT — automatic validation by SDK | +| Manual verification | Required if using raw HTTP | Required only without SDK (REST-only integration) | +| Bot credentials | `SLACK_BOT_TOKEN` (xoxb-...) | `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` | +| App-level token | `SLACK_APP_TOKEN` (xapp-..., Socket Mode only) | Not applicable | + +**Rating:** GREEN — both SDKs handle verification automatically. + +**Mitigation:** No code changes needed when using SDKs. Both handle signing/verification internally. For REST-only integrations, see the verification patterns below. + +### REST-Only Verification + +**Slack (manual HMAC):** +``` +signature = HMAC-SHA256(signingSecret, "v0:{timestamp}:{rawBody}") +compare with X-Slack-Signature header +reject if timestamp > 5 minutes old +``` + +**Teams (manual JWT):** +``` +fetch OpenID config from https://login.botframework.com/v1/.well-known/openidconfiguration +validate JWT from Authorization header +verify audience = your bot's CLIENT_ID +verify issuer = https://api.botframework.com +``` + +--- + +## OAuth & Tokens + +| Aspect | Slack | Teams | +|---|---|---| +| User OAuth | `users:read`, `chat:write`, etc. scopes | Azure AD Graph permissions (delegated) | +| Bot token | `xoxb-...` (per-workspace) | Bot Framework token (per-tenant, auto-managed) | +| Token storage | `InstallationStore` (per-workspace bot+user tokens) | Not needed — SDK handles token lifecycle | +| SSO | Not native — redirect flow | Built-in with `oauth: { defaultConnectionName }` | +| Sign-in flow | OAuth redirect to Slack authorize URL | `ctx.signin()` sends OAuth card in chat | +| Sign-out | Revoke token via API | `ctx.signout()` | +| Multi-tenant | `InstallationStore` with per-workspace tokens | `signInAudience: "AzureADMultipleOrgs"` in Azure AD | + +**Rating:** YELLOW — both support OAuth but the flows and storage models differ significantly. + +### Key Difference + +Slack requires per-workspace token management via `InstallationStore`. Teams SDK manages tokens automatically — you just configure `clientId`, `clientSecret`, `tenantId`, and `oauth.defaultConnectionName`. + +### Mitigation (Slack → Teams) + +1. Remove `InstallationStore` (not needed) +2. Register an OAuth connection in Azure Bot resource settings +3. Add `oauth: { defaultConnectionName: "graph" }` to App constructor +4. Guard handlers with `ctx.isSignedIn` check +5. Call `ctx.signin()` when authentication is needed + +### Mitigation (Teams → Slack) + +1. Implement `InstallationStore` for per-workspace token storage +2. Configure OAuth scopes in Slack app settings +3. Set up `InstallProvider` for the OAuth install flow +4. Store bot and user tokens per workspace +5. Use stored tokens for API calls via `WebClient` + +### Slack OAuth Scopes → Teams Graph Permissions + +| Slack Scope | Teams Graph Permission | Notes | +|---|---|---| +| `users:read` | `User.Read` (delegated) | | +| `users:read.email` | `User.Read` (delegated) | Email included by default | +| `chat:write` | Bot sends via SDK (no permission) | | +| `channels:read` | `Channel.ReadBasic.All` | | +| `channels:history` | `ChannelMessage.Read.All` | | +| `files:read` | `Files.Read` | | +| `files:write` | `Files.ReadWrite` | | diff --git a/skills/microsoft-365-agents-toolkit/docs/infrastructure.md b/skills/microsoft-365-agents-toolkit/docs/infrastructure.md new file mode 100644 index 000000000..79853e7d1 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/infrastructure.md @@ -0,0 +1,178 @@ +# Infrastructure + +## Transport + +| Aspect | Slack | Teams | +|---|---|---| +| Primary transport | Socket Mode (WebSocket) or HTTP | **HTTPS only** (inbound webhook) | +| Firewall-friendly | Socket Mode — outbound WebSocket, no inbound ports | **Requires public HTTPS endpoint** | +| Default endpoint | `/slack/events` | `/api/messages` | +| Local development | Socket Mode (no tunnel needed) | Dev Tunnels or ngrok required | +| Request verification | HMAC-SHA256 (`signingSecret`) | Bot Framework JWT (automatic) | + +**Rating:** GREEN for HTTP-to-HTTPS, RED for Socket Mode → HTTPS (firewall environments). + +### Impact + +Slack bots using Socket Mode run behind firewalls with zero inbound ports. Teams requires a public HTTPS endpoint — a fundamental architecture change for firewall-restricted environments. + +### Mitigation (Slack Socket Mode → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Deploy to Azure (Recommended)** | Host in Azure App Service / Functions / Container Apps. Use Dev Tunnels for local dev. | 4–8 hrs | +| **Azure Relay** | Hybrid connection for strict on-premises firewalls. Adds latency. | 8–16 hrs | + +### Dual-Bot Transport + +For bots targeting both platforms simultaneously: + +| Pattern | How | +|---|---| +| **Socket Mode + HTTP (Recommended)** | Slack uses WebSocket (no HTTP needed), Teams uses Express on port 3978. No port conflicts. Simplest setup. | +| **Shared Express** | Both use HTTP. Slack `ExpressReceiver` at `/slack/events`, Teams adapter at `/api/messages`. Requires careful body-parsing middleware ordering. | + +--- + +## Compute (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| Lambda + API Gateway | Azure Functions | Teams bots need 3-second response; Functions Consumption has 5–10s cold starts | +| ECS / Fargate | Container Apps | Best for long-running bots with streaming | +| EC2 | App Service | Always-on, predictable latency | + +### Cold Start Warning + +Azure Functions Consumption plan has 5–10 second cold starts that violate the Teams 3-second response timeout. Mitigations: + +| Strategy | Cost Impact | +|---|---| +| **App Service with Always On (Recommended)** | Fixed cost but no cold starts | +| **Functions Premium with Always Ready** | Higher cost, eliminates cold starts | +| **Container Apps (min replicas ≥ 1)** | Moderate cost, no scale-to-zero | + +--- + +## Storage (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| S3 | Blob Storage | Hot/Cool/Archive tiers | +| DynamoDB | Cosmos DB | Table API (lowest effort) or Core SQL (richer querying) | +| RDS (MySQL) | Azure Database for MySQL | Managed migration service available | +| RDS (PostgreSQL) | Azure Database for PostgreSQL | Managed migration service available | +| RDS (SQL Server) | Azure SQL | Direct migration path | + +### Bot State Storage + +| Aspect | Slack | Teams | +|---|---|---| +| SDK storage | No built-in state management | `IStorage` interface with pluggable backends | +| Default | Developer manages state | In-memory (lost on restart) | +| Production | External DB (Redis, PostgreSQL, etc.) | Cosmos DB, Azure SQL, or custom `IStorage` | + +**Mitigation:** Implement the Teams `IStorage` interface with Cosmos DB for bot state. Use serverless pricing for development, provisioned RUs for production. + +--- + +## Secrets & Configuration + +| AWS | Azure | Notes | +|---|---|---| +| Secrets Manager | Key Vault | Sensitive credentials | +| SSM Parameter Store | App Configuration | Non-secret configuration | +| IAM roles | Managed Identity | Zero-secret authentication | +| Environment variables | App Settings | Runtime configuration | + +### Bot Credentials + +| Credential | Slack | Teams | +|---|---|---| +| Bot token | `SLACK_BOT_TOKEN` | Managed by SDK (`CLIENT_ID` + `CLIENT_SECRET`) | +| Signing/verification | `SLACK_SIGNING_SECRET` | Automatic JWT validation | +| Socket Mode | `SLACK_APP_TOKEN` | N/A | +| Tenant | N/A | `TENANT_ID` | + +### Production Secret Management + +| Strategy | How | +|---|---| +| **Key Vault references (Recommended)** | `@Microsoft.KeyVault(SecretUri=...)` in App Settings. Zero-code secret injection. Requires managed identity. | +| **Managed identity for bot auth** | `managedIdentityClientId: "system"` in App constructor. Eliminates `CLIENT_SECRET` entirely. | +| **`DefaultAzureCredential`** | Chains managed identity → environment → CLI → VS Code. Works everywhere. | + +--- + +## Observability (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| CloudWatch Logs | Application Insights + Log Analytics | KQL query language (different from CloudWatch Insights) | +| CloudWatch Metrics | Azure Monitor Metrics | `trackMetric()` | +| CloudWatch Alarms | Azure Monitor Alerts | KQL-based alerting | +| X-Ray | Application Insights distributed tracing | Operation IDs, `traceparent` headers | + +### Bot Health Monitoring + +Key metrics to track for both platforms: + +| Metric | Why | +|---|---| +| Request rate | Volume baseline | +| Response time (P50/P95/P99) | Detect slowdowns before they cause timeouts | +| Failure rate | Catch errors before users report them | +| Active conversations | Usage trends | +| AI/external API latency | Dependency health | + +### Setup + +```typescript +// Application Insights — must be first import +import appInsights from "applicationinsights"; +appInsights.setup(process.env.APPLICATIONINSIGHTS_CONNECTION_STRING).start(); + +// Then import everything else +import { App } from "@microsoft/teams.apps"; +``` + +**Pitfall:** Late instrumentation import. `applicationinsights` must run before `http`/`https` are loaded or distributed tracing won't work. + +--- + +## Rate Limiting & Resilience + +| Aspect | Slack | Teams | +|---|---|---| +| Rate limit signal | HTTP 429 + `Retry-After` header | HTTP 429 + `Retry-After` header | +| Built-in retry | Bolt `retryConfig` option | **No built-in retry** | +| Conversation limits | ~1 msg/sec per method per token | ~1 msg/sec per conversation, ~30 msg/min per conversation | +| Graph API limits | N/A | Separate throttling (per-app per-tenant) | +| Invoke timeout | N/A | 3–10 seconds (varies by invoke type) | + +**Rating:** GREEN for basic rate limiting, YELLOW for resilience patterns. + +### Mitigation (Slack → Teams) + +Build a `RetryPlugin` with exponential backoff + jitter: + +| Component | Purpose | +|---|---| +| **Exponential backoff** | Wait 1s, 2s, 4s, 8s between retries | +| **Jitter** | Add random delay to prevent thundering herd | +| **Circuit breaker** | Stop retrying after N consecutive failures | +| **`p-queue`** | Concurrency control for proactive broadcast (avoid bursting) | + +Effort: 12–16 hrs for a production-grade retry plugin. + +### Reverse Direction (Teams → Slack) + +Use Bolt's built-in `retryConfig` option: + +```typescript +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + // Built-in retry with exponential backoff +}); +``` diff --git a/skills/microsoft-365-agents-toolkit/docs/interactive-responses.md b/skills/microsoft-365-agents-toolkit/docs/interactive-responses.md new file mode 100644 index 000000000..16d81fe31 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/interactive-responses.md @@ -0,0 +1,98 @@ +# Interactive Responses + +## Ephemeral Messages + +| Aspect | Slack | Teams | +|---|---|---| +| User-only messages | `chat.postEphemeral()` or `respond({ response_type: "ephemeral" })` | **No native equivalent** | +| Per-user card views | Not available (use ephemeral messages) | `Action.Execute` with `refresh.userIds` | +| Default command response | Ephemeral | Visible to everyone | + +**Rating:** RED (Slack → Teams), GREEN (Teams → Slack). + +### Impact + +Any Slack bot that uses ephemeral messages for user-only feedback — confirmation dialogs, error messages, inline help — has no direct Teams equivalent. Messages are visible to everyone unless workarounds are used. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`refresh.userIds` (Recommended)** | Adaptive Cards with `Action.Execute` and `refresh.userIds` show different card content per user. Covers ~80% of cases. Limited to 60 user IDs. | 4–8 hrs | +| **1:1 chat fallback** | Route ephemeral content to user's personal bot chat via proactive messaging. Different UX (separate conversation) but reliable. | 2–4 hrs | +| **`sendEphemeral()` helper** | Wrapper that auto-detects context and picks the best strategy. Worth it if reused across multiple handlers. | 8–12 hrs | +| **Drop ephemeral behavior** | Show messages to everyone. Simplest but may expose private data. | 0 hrs | + +### Reverse Direction (Teams → Slack) + +`refresh.userIds` per-user card views map to Slack's native ephemeral messages. Use `chat.postEphemeral()` directly. + +--- + +## Message Updates and Replacements + +| Aspect | Slack | Teams | +|---|---|---| +| Replace original | `respond({ replace_original: true })` | Return card from invoke handler, or `ctx.updateActivity(activityId)` | +| Delete original | `respond({ delete_original: true })` | `ctx.deleteActivity(activityId)` | +| Update by ID | `chat.update({ ts, channel, ... })` | `ctx.updateActivity({ id: activityId, ... })` | +| Response URL | `response_url` — valid 30 min, max 5 uses | No equivalent concept | +| Activity ID | `message.ts` (timestamp-based) | `activity.id` or `activity.replyToId` | + +**Rating:** GREEN — direct mapping with different API shapes. + +### Key Difference + +Slack uses `response_url` (a webhook URL that expires after 30 minutes and allows up to 5 responses). Teams has no `response_url` — you update messages by storing and referencing the `activity.id`. + +**Mitigation:** Store the `activity.id` when sending messages that may need updating. Use `ctx.updateActivity()` with the stored ID. + +--- + +## Button Actions + +| Aspect | Slack | Teams | +|---|---|---| +| Handler registration | `app.action("action_id", handler)` | `app.on("card.action", handler)` routing on `data.action` | +| Action identifier | `action_id` on button element | `data.action` (or `data.verb`) in `Action.Submit` | +| Button value | `action.value` | `activity.value.action.data` | +| Acknowledgement | Must `ack()` within 3 seconds | Automatic | +| Follow-up response | `respond()` (response_url) | `ctx.send()` or `ctx.updateActivity()` | + +**Rating:** GREEN — direct mapping with different routing mechanisms. + +**Mitigation:** In Slack, each button has a unique `action_id` with its own handler. In Teams, all `Action.Submit` buttons route through `card.action`; use a `data.action` field to dispatch: + +```typescript +// Teams — route by data.action +app.on("card.action", async (ctx) => { + const action = ctx.activity.value?.action?.data?.action; + switch (action) { + case "approve": /* ... */ break; + case "reject": /* ... */ break; + } +}); +``` + +--- + +## Confirmation Dialogs + +| Aspect | Slack | Teams | +|---|---|---| +| Native support | `confirm` object on button elements | **No native equivalent** | +| Behavior | Platform-rendered "Are you sure?" popup | Must be built manually | + +**Rating:** YELLOW (Slack → Teams), GREEN (Teams → Slack). + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`Action.ShowCard` inline (Recommended)** | Inline expand with "Are you sure?" text and Yes/No buttons. Native Adaptive Card pattern. | 2–4 hrs | +| **Task module confirm** | Small dialog popup for confirmation. More prominent, closer to Slack UX. | 4–6 hrs | +| **`confirmAction()` helper** | Template function generating confirm cards. Reusable across multiple buttons. | 4–8 hrs | + +### Reverse Direction (Teams → Slack) + +Use the native `confirm` object on button elements. Built-in, no custom code needed. diff --git a/skills/microsoft-365-agents-toolkit/docs/messaging-and-commands.md b/skills/microsoft-365-agents-toolkit/docs/messaging-and-commands.md new file mode 100644 index 000000000..72a559659 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/messaging-and-commands.md @@ -0,0 +1,102 @@ +# Messaging & Commands + +## Message Handling + +| Aspect | Slack | Teams | +|---|---|---| +| Handler | `app.message(pattern, handler)` | `app.on("message", handler)` | +| Pattern matching | String (substring), RegExp, or catch-all | RegExp or manual `text.match()` | +| Reply to channel | `say(text)` | `ctx.send(text)` | +| Reply in thread | `say({ text, thread_ts })` | `ctx.reply(text)` | +| Get message text | `message.text` | `ctx.activity.text` | +| Get sender | `message.user` (Slack ID) | `ctx.activity.from.id` (AAD ID) | + +**Rating:** GREEN — direct mapping in both directions. + +**Mitigation:** Extract message handling into a platform-agnostic service layer that receives `(text, userId, platform)` and returns structured data. Each adapter converts to the platform's native format. + +--- + +## Slash Commands + +| Aspect | Slack | Teams | +|---|---|---| +| Invocation | `/command args` | No native equivalent | +| Handler | `app.command("/cmd", handler)` | `app.on("message")` with text pattern matching | +| Acknowledgement | Must `ack()` within 3 seconds | Automatic — no `ack()` | +| Default response | Ephemeral (user-only) | Visible to everyone | +| Modal trigger | `trigger_id` from command → `views.open()` | `dialog.open` handler or Adaptive Card form | +| Registration | Slack app dashboard + `commands` scope | Manifest `commands[]` array (bot commands, not slash) | + +**Rating:** YELLOW — functional equivalent exists but UX is fundamentally different. + +### Impact + +- Slash commands are a core Slack interaction pattern with no Teams counterpart +- Teams bot commands appear in a command menu but don't use `/` prefix +- Ephemeral responses don't exist in Teams + +### Mitigation Strategies + +| Strategy | How | Effort | +|---|---|---| +| **Text commands (Recommended)** | Detect command-like patterns in `app.on("message")`. Accept both `weather` and `/weather`. | 2–4 hrs | +| **Manifest bot commands** | Add `commands[]` to manifest for discoverability in Teams command menu. Users type the command name. | 1–2 hrs | +| **Message extension** | Use `composeExtensions` for a richer command experience with search results or task modules. | 8–12 hrs | + +### Reverse Direction (Teams → Slack) + +Teams bot commands map directly to Slack slash commands via `app.command()`. Add `ack()` calls (required in Slack, absent in Teams) and configure the command in the Slack app dashboard. + +--- + +## Events / Activities + +| Slack Event | Teams Activity | Notes | +|---|---|---| +| `message` | `message` | Direct mapping | +| `app_mention` | `message` (in channel) | Teams channels require @mention by default | +| `member_joined_channel` | `conversationUpdate` (`membersAdded`) | Different event shape | +| `member_left_channel` | `conversationUpdate` (`membersRemoved`) | Different event shape | +| `reaction_added` | `messageReaction` | Teams has only 6 fixed reactions | +| `app_home_opened` | `install.add` (closest) | No "opened" event in Teams | +| `channel_created` | No equivalent | Use Graph API subscription | +| `team_join` | `conversationUpdate` (`membersAdded`) | Same event, different context | + +**Rating:** GREEN for most events, RED for custom emoji reactions. + +### @Mention Behavior + +| Aspect | Slack | Teams | +|---|---|---| +| Channel messages | Bot receives all messages in joined channels | Bot only receives messages with @mention (default) | +| Override | Default behavior | Add `ChannelMessage.Read.Group` RSC permission to manifest | +| Mention stripping | Not needed | Set `activity.mentions.stripText: true` in App options | + +**Mitigation:** To receive all channel messages in Teams without @mention, add RSC permission to the manifest. This is a config-only change (1–2 hrs). + +--- + +## Threading + +| Aspect | Slack | Teams | +|---|---|---| +| Reply in thread | `say({ thread_ts: message.ts })` | `ctx.reply(text)` | +| Thread broadcast | `say({ thread_ts, reply_broadcast: true })` | Two API calls: `reply()` + `send()` | +| Get thread replies | `conversations.replies({ ts })` | Graph API `GET /messages/{id}/replies` | +| Thread discovery | Native API | Requires `ChannelMessage.Read.All` Graph permission | + +**Rating:** GREEN for basic threading, YELLOW for broadcast and discovery. + +### Mitigation for Thread Broadcast + +Slack's `reply_broadcast` posts in both the thread and the channel in one call. Teams requires two separate calls: `reply()` for the thread and `send()` for the channel. Wrap in a helper: + +```typescript +async function replyWithBroadcast(ctx, text: string): Promise { + await ctx.reply(text); // Thread reply + await ctx.send(text); // Channel message +} +``` + +Effort: 1–2 hrs. diff --git a/skills/microsoft-365-agents-toolkit/docs/middleware-and-handlers.md b/skills/microsoft-365-agents-toolkit/docs/middleware-and-handlers.md new file mode 100644 index 000000000..c0f73f2b1 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/middleware-and-handlers.md @@ -0,0 +1,97 @@ +# Middleware & Handler Patterns + +## Middleware + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Global middleware | `app.use(async ({ next }) => { ... await next(); })` | `app.use(async (ctx) => { ... ctx.next(); })` | +| Chaining | Explicit `await next()` — omitting drops the event silently | Explicit `ctx.next()` — omitting stops the pipeline | +| Listener middleware | Passed as extra args to `app.message(filter, middleware, handler)` | No equivalent — use guard functions at handler start | +| Authorization | Custom middleware checking Slack user/workspace | Bot Framework JWT validation is automatic | + +**Rating:** GREEN — both have middleware, but Slack's is more granular. + +### Key Difference + +Slack supports **listener middleware** — functions that run only for specific handlers. Teams has no equivalent. Convert listener middleware to guard conditions at the top of each handler: + +```typescript +// Slack: listener middleware +app.message(isAdmin, async ({ say }) => { await say("Admin action"); }); + +// Teams: guard function +app.on("message", async (ctx) => { + if (!isAdmin(ctx.activity.from.id)) return; + await ctx.send("Admin action"); +}); +``` + +--- + +## Acknowledgement (`ack()`) + +| Aspect | Slack | Teams | +|---|---|---| +| Required for | Commands, actions, view submissions, shortcuts, options | **Not applicable** — SDK handles automatically | +| Deadline | 3 seconds | No manual acknowledgement | +| What happens if missed | Slack shows "This app didn't respond" error to user | N/A | +| Payload in ack | Commands: optional text/blocks (ephemeral). View submissions: optional `response_action`. | N/A | + +**Rating:** GREEN — remove `ack()` calls when porting Slack → Teams. + +### Impact + +`ack()` is fundamental to Slack's interaction model. Every interactive handler must acknowledge within 3 seconds or the user sees an error. Teams has no equivalent — the SDK handles response timing automatically. + +### Mitigation + +| Direction | Strategy | +|---|---| +| Slack → Teams | Remove all `ack()` calls. Move async work that previously happened "after ack" into the main handler body. | +| Teams → Slack | Add `await ack()` as the first line of every command, action, view, shortcut, and options handler. Do async work after. | + +--- + +## Handler Registration + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Messages | `app.message(pattern, handler)` | `app.message(pattern, handler)` or `app.on("message", handler)` | +| Events | `app.event("event_name", handler)` | `app.on("routeName", handler)` | +| Actions | `app.action("action_id", handler)` | `app.on("card.action", handler)` — route by `data.action` | +| Modals | `app.view("callback_id", handler)` | `app.on("dialog.submit", handler)` | +| Shortcuts | `app.shortcut("callback_id", handler)` | `app.on("message.ext.open", handler)` | +| Options/typeahead | `app.options("action_id", handler)` | `Input.ChoiceSet` with `style: "filtered"` (client-side) | +| Install events | No built-in handler | `app.on("install.add", handler)` | +| Lifecycle events | No built-in handler | `app.event("start" | "error" | "signin" | "activity")` | +| Order matters | First `app.message()` match wins | First match wins for `app.message()`, last registration wins for `app.on()` | + +**Rating:** GREEN — different APIs, same concepts. + +### Key Mapping + +``` +Slack Teams +───────────────────────────── ───────────────────────────── +app.message(pattern) → app.message(pattern) +app.command("/cmd") → app.on("message") + text match +app.action("id") → app.on("card.action") +app.view("callback_id") → app.on("dialog.submit") +app.event("name") → app.on("routeName") +app.shortcut("id") → app.on("message.ext.open") +app.options("id") → (client-side filtered ChoiceSet) +app.use(middleware) → app.use(middleware) +app.error(handler) → app.event("error", handler) +``` + +--- + +## Error Handling + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Global handler | `app.error(async (error) => { ... })` | `app.event("error", ({ error, log }) => { ... })` | +| Unhandled errors | Logged to stderr, process continues | Logged, process continues | +| Per-handler errors | try/catch in individual handlers | try/catch in individual handlers | + +**Rating:** GREEN — equivalent patterns. diff --git a/skills/microsoft-365-agents-toolkit/docs/ui-components.md b/skills/microsoft-365-agents-toolkit/docs/ui-components.md new file mode 100644 index 000000000..caeb4a999 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/docs/ui-components.md @@ -0,0 +1,137 @@ +# UI Components + +## Block Kit vs Adaptive Cards + +| Slack Block Kit | Adaptive Card Element | Notes | +|---|---|---| +| `section` (text) | `TextBlock` | Set `wrap: true`; convert `*bold*` mrkdwn → `**bold**` Markdown | +| `section` (fields) | `FactSet` | Each field becomes `{ title, value }` | +| `section` (text + accessory) | `ColumnSet` with 2 `Column`s | Col 1 = text, Col 2 = accessory | +| `header` | `TextBlock` size `Large`, weight `Bolder` | | +| `actions` | `ActionSet` | Max 6 actions in Teams (vs 25 in Slack) | +| `divider` | `TextBlock` with `separator: true` | | +| `image` | `Image` | `alt_text` (underscore) → `altText` (camelCase) | +| `context` | `TextBlock` size `Small`, `isSubtle: true` | | +| `input` (plain_text) | `Input.Text` | | +| `input` (static_select) | `Input.ChoiceSet` `style: "compact"` | | +| `input` (multi_select) | `Input.ChoiceSet` `isMultiSelect: true` | | +| `input` (datepicker) | `Input.Date` | | +| `input` (timepicker) | `Input.Time` | | +| `input` (checkboxes) | `Input.ChoiceSet` `style: "expanded"`, `isMultiSelect: true` | | +| `input` (radio_buttons) | `Input.ChoiceSet` `style: "expanded"` | | +| `rich_text` | `RichTextBlock` | Schema 1.5+ | +| `overflow` menu | **No equivalent** | Redesign as `ActionSet` or `Input.ChoiceSet` dropdown | + +**Rating:** GREEN for most elements, RED for overflow menus. + +### Markdown Differences + +| Formatting | Slack mrkdwn | Adaptive Card Markdown | +|---|---|---| +| Bold | `*bold*` | `**bold**` | +| Italic | `_italic_` | `_italic_` | +| Strikethrough | `~strike~` | `~~strike~~` | +| Code | `` `code` `` | `` `code` `` | +| Emoji | `:emoji_shortcode:` | Unicode characters only | +| User mention | `<@U12345>` | Display name (no mention syntax) | + +**Impact:** Failing to convert `*bold*` to `**bold**` produces literal asterisks in Teams. Slack emoji shortcodes render as plain text in Adaptive Cards. + +**Mitigation:** Apply a text transform function when converting between formats: + +```typescript +// Slack mrkdwn → Adaptive Card Markdown +text.replace(/(? 0, there's a conflict. The bot renders the conflicting bookings and suggests the next available slot. + +--- + +## Scenario 4: Account Health Monitoring (CRM) + +**Audience:** Sales teams, account managers, customer success. + +### User Flow + +1. Weekly scheduled prompt posts to the sales channel: "Time for account health check-ins" +2. Each account owner fills in: Account name, health status (Green/Yellow/Red), notes, next meeting date +3. Responses aggregate into a durable account health list +4. Stale accounts flagged: if no update in 30 days, bot sends a dormant account alert +5. Before a meeting, manager types "summarize Acme Corp" — AI pulls the last 4 check-ins and renders a trend card + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | Weekly cron schedule. Dormant-account check (daily timer queries for last-update > 30 days) | +| State | SharePoint List: AccountName, Owner, HealthStatus (Green/Yellow/Red), Notes, NextMeeting, LastUpdated | +| Logic | Staleness detection: daily timer queries `fields/LastUpdated lt '{30-days-ago}'`. Proactive alert to owner | +| Intelligence | `queryAccountHealth(account?, status?, owner?)` — "Show all red accounts", "Summarize Acme Corp history" | +| Visibility | Check-in prompt card. Account status card (color-coded). Dormant account alert. Trend summary card | + +### Trend Analysis + +The AI function returns the last N check-ins for an account. The LLM summarizes: + +> *"Acme Corp: 4 check-ins over the last month. Trend: Yellow → Yellow → Red → Red. Key issue: delayed contract renewal (first flagged March 1). Next meeting: March 15."* + +This is the "intelligence layered over structured state" pattern — the primary differentiation opportunity called out in the source document. + +--- + +## Scenario 5: Frontline Break Management + +**Audience:** Frontline workers, call centers (e.g., T-Mobile scenario from source document). + +### User Flow + +1. Agent changes presence to "Away" (auto-detected via Graph presence subscription) +2. Bot removes agent from call queue and starts break timer +3. At 15 minutes, bot sends a reminder card to the agent and their manager +4. At 20 minutes, bot escalates — posts an alert card in the manager channel +5. Agent changes presence to "Available" — bot re-adds to queue, records break duration +6. Manager types "who is on break?" or "average break duration today" — AI queries and responds + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | Graph change notification subscription on `/communications/presences/{userId}` | +| State | SharePoint List: EmployeeName, BreakStart, BreakEnd, DurationMinutes, Status (Active/Ended/Escalated) | +| Logic | Timer-based escalation (15 min reminder, 20 min escalate). Call queue add/remove via Teams admin APIs. Break record created on "Away", updated on "Available" | +| Intelligence | `queryBreakStatus(currentOnly?)` — "Who is on break right now?", "Average break duration this week" | +| Visibility | Break started card (in manager channel). Reminder card (to agent). Escalation alert card. Break summary card | + +### Why This Is Teams-Native + +This scenario depends on three capabilities Slack cannot replicate: + +| Capability | Teams | Slack | +|---|---|---| +| Presence change subscriptions | Graph `/communications/presences` | Not available | +| Shift schedule integration | Shifts API | Not available | +| Call queue management | Teams admin APIs + Graph | Not available | + +### Technical Requirements + +- **Graph subscription for presence** requires `Presence.Read.All` application permission and encrypted rich notifications (public/private key pair for notification decryption) +- **Presence subscriptions expire in 60 minutes** — aggressive renewal required (55-minute interval) +- **Webhook must respond in 3 seconds** — process notifications asynchronously +- **In-memory timers don't survive restarts** — use Azure Durable Functions or a Redis-backed job queue for production + +--- + +## Scenario 6: Incident Response + +**Audience:** IT operations, DevOps, on-call teams. + +### User Flow + +1. On-call engineer types `/incident P1 Production database connection pool exhausted` +2. Bot creates an incident record, posts a structured incident card, and creates a dedicated incident thread +3. Bot proactively notifies the on-call rotation (looked up from a Shifts schedule or list) +4. Team members post updates in the thread — bot captures tagged updates (`/update Database restarted, monitoring`) +5. Engineer types `/resolve` — bot closes the incident, calculates MTTR, and posts a resolution summary +6. Post-incident: manager types "show P1 incidents this month" — AI generates a summary with MTTR trends + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | `/incident PRIORITY DESCRIPTION` bot command | +| State | SharePoint List: IncidentId, Priority (P1-P4), Description, Status (Open/Investigating/Resolved), AssignedTo, CreatedAt, ResolvedAt, MTTR, Updates[] | +| Logic | Auto-assign from on-call rotation. Status transitions: Open → Investigating → Resolved. MTTR calculation on resolve. Thread-based update capture | +| Intelligence | `queryIncidents(priority?, status?, dateRange?)` — "Show open incidents", "MTTR trend for P1s this quarter" | +| Visibility | Incident card (color-coded by priority). Update timeline in thread. Resolution summary card with MTTR | + +--- + +## Composable Platform Pattern + +All six scenarios follow the same lifecycle. The composable platform approach (see `bridge/workflow.composable-platform-ts.md`) defines workflows as configuration: + +```typescript +interface WorkflowDefinition { + id: string; // "pto", "standup", "equipment" + commandPrefix: string; // "/pto", "/standup", "/book" + columns: ColumnDefinition[]; // SharePoint List schema + statusField: string; // Which column tracks lifecycle + routing?: RoutingConfig; // Approval chain config + cards: CardTemplates; // Active, completed, list, form + queryDescription: string; // AI function calling description + filterableColumns: string[]; // Columns exposed to NL queries +} +``` + +A single workflow engine registers handlers from definitions. New workflows require a new `WorkflowDefinition` object, not new handler code. Template workflows (standup, PTO, equipment) serve as reference implementations. + +### Scenario Comparison + +| Scenario | Trigger Types | Approval | State-Driven | NL Queries | Competitive Edge | +|---|---|---|---|---|---| +| Daily Standup | Scheduled, command | No | No | Blockers, summaries | Structured check-ins as durable records | +| PTO Requests | Command, extension | Yes (single/chain) | No | Status, date range, person | Approval routing + NL retrieval | +| Equipment Booking | Command, search | No | No | Availability, overdue | Conflict detection + alternatives | +| Account Health | Scheduled | No | Timer (staleness) | Trends, status, owner | Trend analysis over time | +| Break Management | Presence change | No | Yes (presence) | Current status, averages | Teams-only: presence + Shifts + call queues | +| Incident Response | Command | No | No | Priority, MTTR, status | Thread-based update capture + MTTR | + +--- + +## Platform Comparison: Teams vs Slack + +| Capability | Slack | Teams | Gap | +|---|---|---|---| +| In-channel workflow creation | Workflow Builder GUI | Power Automate (external) | Teams gap: no in-channel builder | +| Structured input forms | `OpenForm` built-in function | Adaptive Card forms (bot) or task modules | Parity | +| State persistence | Datastores (50K limit, Slack-hosted) | SharePoint Lists (30M limit, tenant-owned) | Teams advantage | +| Card interactivity | Block Kit (new message on action) | Action.Execute (in-place refresh) | Teams advantage | +| NL querying over state | Not built-in | AI function calling + structured data | Teams advantage | +| Presence/Shifts triggers | Not available | Graph subscriptions | Teams advantage | +| Call queue integration | Not available | Teams admin APIs | Teams advantage | +| No-code authoring | Workflow Builder | Power Automate | Slack advantage (simpler UX) | +| Hosting model | Slack-hosted (Deno) | Self-hosted or Azure | Trade-off | + +The core thesis: if Teams unifies its existing primitives at the message layer (which a bot can do today), it moves beyond parity — especially for operational and frontline workflows where Slack lacks system-level integration. diff --git a/skills/microsoft-365-agents-toolkit/experts/README.md b/skills/microsoft-365-agents-toolkit/experts/README.md new file mode 100644 index 000000000..10a53b6a5 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/README.md @@ -0,0 +1,133 @@ +# Bot Platform Expert System + +A curated knowledge base for building conversational bots and AI agents across Slack and Microsoft Teams. These micro-experts guide AI coding assistants (Claude, Copilot, etc.) to produce correct, idiomatic code by loading only the relevant expertise for each task. + +## Goals + +1. **Accelerate bot development** by giving AI assistants deep, verified knowledge of both Slack and Teams SDKs — eliminating hallucinated APIs and outdated patterns. +2. **Support cross-platform scenarios** where a single team needs to ship bots on both Slack and Teams from the same codebase. +3. **Cover the full stack** from SDK initialization and webhook plumbing through AI integration, media handling, and infrastructure migration — not just "hello world" examples. +4. **Stay language-pragmatic** by focusing on TypeScript (the only language with first-class SDK support on both platforms) while providing REST-level guidance for Java, C#, Go, and other languages. + +## SDK Language Matrix + +| Language | Slack Bolt | Teams SDK | Recommendation | +|--------------------|-----------|-----------|---------------------------------------------------------------| +| TypeScript / JS | Full | Full | Best choice for dual-platform — both SDKs are first-class | +| Python | Full | Preview | Good for AI/ML workloads; Teams SDK still maturing | +| Java / JVM | Full | None | Use REST-only patterns for Teams (see `rest-only-integration`) | +| C# / .NET | None | Full | Use REST-only patterns for Slack (see `rest-only-integration`) | +| Go, Ruby, etc. | None | None | REST-only for both platforms | + +## Scenarios + +### 1. Build a Teams bot (TypeScript) + +Load the **Teams** domain. 28 micro-experts cover app initialization, routing, Adaptive Cards, dialogs, message extensions, OAuth/SSO, Graph API, AI (ChatPrompt, function calling, RAG, streaming, memory), MCP, A2A, and more. + +**Key experts:** `teams/runtime.app-init-ts.md`, `teams/runtime.routing-handlers-ts.md`, `teams/ui.adaptive-cards-ts.md` + +### 2. Build a Slack bot (TypeScript) + +Load the **Slack** domain. 7 micro-experts cover Bolt.js app setup, handler registration, ack rules, slash commands, Block Kit UI, Events API, Assistant containers, and OAuth/multi-workspace distribution. + +**Key experts:** `slack/runtime.bolt-foundations-ts.md`, `slack/bolt-events-ts.md`, `slack/bolt-assistant-ts.md` + +### 3. Host both bots in a single server + +Load the **Bridge** domain's architecture cluster. Covers shared Express server with route separation, Socket Mode + HTTP dual receiver, platform-agnostic service layer, and identity normalization. + +**Key expert:** `bridge/cross-platform-architecture-ts.md` + +### 4. Integrate from Java, C#, or Go (no native SDK) + +Load the **Bridge** domain's REST-only cluster. Language-agnostic pseudocode for Bot Framework REST API (Teams) and Slack Events API + Web API — manual JWT validation, HMAC signature verification, token acquisition, and message sending. + +**Key expert:** `bridge/rest-only-integration-ts.md` + +### 5. Bridge features between Slack and Teams + +Load the **Bridge** domain. 25 micro-experts cover bidirectional mapping of every feature: Block Kit ↔ Adaptive Cards, commands, events ↔ activities, identity, modals ↔ dialogs, files, shortcuts ↔ extensions, workflows ↔ Power Automate, infrastructure (Lambda ↔ Functions, S3 ↔ Blob), and more. + +**Key expert:** `bridge/cross-platform-advisor-ts.md` (orchestrates the full bridging workflow) + +### 6. Deploy your bot to Azure or AWS + +Load the **Deploy** domain. The router interviews you on cloud provider preference (Azure or AWS) and bot platform (Teams, Slack, or both), then loads the matching expert for a step-by-step walkthrough from CLI installation through verified deployment. + +**Key experts:** `deploy/azure-bot-deploy-ts.md`, `deploy/aws-bot-deploy-ts.md` + +### 7. Convert code from another language to TypeScript + +Load the **Convert** domain. 8 micro-experts cover JS→TS, Ruby→TS, Java→TS, Kotlin→TS, type mapping, dependency mapping, JSON serialization, and bulk conversion strategy. + +**Key experts:** `convert/java-to-ts-ts.md`, `convert/kotlin-to-ts-ts.md`, `convert/type-mapping-ts.md` + +## Expert Inventory + +### Root (6 files) +| File | Purpose | +|------|---------| +| `index.md` | Root task router — interviews developer, routes to domain | +| `fallback.md` | Recovery when no domain matches | +| `_expert-ts.md` | Template for creating new experts | +| `researcher.md` | Deep research workflow for fleshing out experts | +| `analyzer.md` | Analyze project and recommend new experts | +| `builder.md` | Build new experts from analysis recommendations | + +### Slack Domain (18 files) +Covers: Bolt.js foundations, ack rules, slash commands, shortcuts, Socket Mode, Block Kit, modals lifecycle, events API, assistant containers, OAuth/distribution, Web API/proactive messaging, Slack CLI (getting started, app management, manifest/triggers, datastore/env, local dev/deploy), Bolt for Python, Bolt for Java. + +### Teams Domain (35 files) +Covers: app init, routing, manifest, proactive messaging, Adaptive Cards, dialogs, message extensions, OAuth/SSO, Graph API, state/storage, AI (ChatPrompt, model setup, function calling, RAG, streaming, citations, memory), MCP (server, client, security, expose tools), A2A (server, client, orchestrator), BotBuilder interop, debug/test, scaffolding, Agents Toolkit (playground, environments, lifecycle CLI, publish), Teams for Python, Teams for .NET. + +### Bridge Domain (26 files) +Covers: Block Kit ↔ Adaptive Cards, commands, events ↔ activities, identity/OAuth bridge, middleware ↔ handlers, modals ↔ dialogs, App Home ↔ personal tab, legacy attachments, transport, infrastructure (compute, storage, secrets, observability), interactive responses, files, link unfurl ↔ preview, shortcuts ↔ extensions, scheduling, channel ops, workflows ↔ automation, distribution/packaging, rate limiting, cross-platform advisor, cross-platform architecture, REST-only integration, Python cross-platform. + +### Convert Domain (8 files) +Covers: JS→TS, Ruby→TS, Java→TS, Kotlin→TS, type mapping, dependency mapping, JSON serialization, bulk conversion strategy. + +### Models Domain (7 files) +Covers: OpenAI/Azure OpenAI, Anthropic, AWS Bedrock, Azure AI Foundry (cloud), Foundry Local, OSS/OpenAI-compatible, Transformers.js. + +### Deploy Domain (4 files) +Covers: Azure deployment (App Service, Functions, Agents Toolkit), AWS deployment (Lambda, API Gateway, ECS, SAM), Azure CLI reference, AWS CLI reference. + +### Security Domain (2 files) +Covers: input validation, secrets management. + +## How It Works + +1. **Developer sends a task** → root `index.md` interviews for scope and preferences +2. **Signal words are scanned** → task routes to exactly one domain router +3. **Domain router matches clusters** → loads only the relevant micro-expert files +4. **Expert-level interviews** (if present) → clarify implementation decisions +5. **Implementation** → expert rules, patterns, and pitfalls guide code generation + +## Eval Harness + +The [`evals/`](../evals/) directory contains an automated test harness that validates the expert system across three dimensions: + +| Dimension | What it checks | LLM required? | +|-----------|---------------|----------------| +| **Patterns** | TypeScript code blocks in experts still compile | No | +| **Routing** | User queries route to the correct domain/clusters/experts | Optional (improves accuracy) | +| **Completeness** | Experts cover all required concepts for their domain | Yes | + +```bash +cd evals && npm install +npm run eval:patterns # fast, no API key +npm run eval # all dimensions (needs OPENAI_API_KEY in .env) +``` + +Current results: 294/294 patterns compile, 41/51 routing cases pass (all 7 domains covered), 9/9 completeness cases pass. The ~10 routing failures are LLM judge scoring edge cases where the deterministic router is correct but the judge scores conservatively on ambiguous or cross-domain queries. See [`evals/README.md`](../evals/README.md) for details. + +After adding or editing experts, run `npm run eval:patterns` to verify code examples still compile. For new domains or significant expert changes, add test cases to `evals/cases/` and run the full suite. + +## Adding New Experts + +Use the `analyzer.md` → `builder.md` workflow: +1. Run `analyzer.md` against a codebase to identify coverage gaps +2. Hand off recommendations to `builder.md` to create expert files +3. New experts auto-wire into domain routers via the post-creation checklist in `_expert-ts.md` +4. Run `cd evals && npm run eval:patterns` to verify new code examples compile diff --git a/skills/microsoft-365-agents-toolkit/experts/_expert-ts.md b/skills/microsoft-365-agents-toolkit/experts/_expert-ts.md new file mode 100644 index 000000000..bd8a15c07 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/_expert-ts.md @@ -0,0 +1,65 @@ +# {topic}-ts + +\## purpose + +{One-line description of what this expert covers.} + +\## rules + +1. {Core rule or pattern #1.} +2. {Core rule or pattern #2.} +3. {Add or remove rules as needed.} + +\## interview (optional — delete if not needed) + + + +\### Q1 — {Decision Topic} +``` +question: "{Clear question ending with ?}" +header: "{Short label, max 12 chars}" +options: + - label: "{Option A} (Recommended)" + description: "{What this option means and effort/tradeoff}" + - label: "{Option B}" + description: "{What this option means and effort/tradeoff}" + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +\### defaults table (required if interview exists) + +| Question | Default | +|---|---| +| Q1 | {Option A — the recommended choice} | + +\## instructions + +Do a web search for: + +\- "{SDK or library name} {specific API or concept} TypeScript {additional keywords}" + +\## research + +Deep Research prompt: + +"{Write a micro expert on {topic} in {SDK/platform} (TypeScript). Cover {key areas}. Include canonical patterns for: {pattern list}.}" + +--- + +\## post-creation checklist + +After creating a new expert from this template, you MUST complete these steps: + +1. **Add to domain `index.md`** — Open the domain's `index.md` (e.g., `teams/index.md`). Either: + - Append the new file to an existing task cluster's `Read:` list, OR + - Create a new task cluster with a `When:` description and `Read:` entry. + - Append the filename to the `## file inventory` list (alphabetical order). + +2. **Update root `index.md` signals** — If the new expert introduces signal words not already covered by the domain's signals list in `.experts/index.md`, add them to the domain's `Signals:` line. + +3. **Verify** — Confirm the file appears in both the domain `index.md` file inventory and the appropriate task cluster `Read:` list. diff --git a/skills/microsoft-365-agents-toolkit/experts/analyzer.md b/skills/microsoft-365-agents-toolkit/experts/analyzer.md new file mode 100644 index 000000000..c9302d5a6 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/analyzer.md @@ -0,0 +1,160 @@ +# analyzer + +## purpose + +Scan a project codebase, identify its technology stack, and recommend micro-experts to create based on coverage gaps against the existing `.experts/` inventory. + +## rules + +1. **Scan manifests first.** Start with package manifests and lock files — they reveal the full dependency tree in seconds. Priority order: `package.json` / `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`, `Cargo.toml` / `Cargo.lock`, `go.mod` / `go.sum`, `pyproject.toml` / `requirements.txt` / `Pipfile`, `pom.xml` / `build.gradle`, `*.csproj` / `*.sln`, `Gemfile`, `Package.swift`, `build.gradle.kts`. +2. **Examine directory structure for framework signals.** Look for conventional directories: `src/app/` or `app/` (Next.js/Remix), `src/routes/` (SvelteKit), `pages/` (Next.js Pages Router), `components/`, `middleware/`, `migrations/`, `prisma/`, `terraform/`, `.github/workflows/`, `.circleci/`, `docker/`, `k8s/`, `helm/`. +3. **Read config files for tooling signals.** Check for: `tsconfig.json`, `.eslintrc.*`, `.prettierrc`, `jest.config.*`, `vitest.config.*`, `playwright.config.*`, `cypress.config.*`, `.dockerignore`, `Dockerfile`, `docker-compose.yml`, `nginx.conf`, `webpack.config.*`, `vite.config.*`, `tailwind.config.*`, `.env.example`. +4. **Catalog the full tech stack.** Produce a structured inventory: language(s), framework(s), build tool(s), test framework(s), CI/CD platform(s), infrastructure/deployment tool(s), notable libraries (ORM, HTTP client, state management, etc.). +5. **Cross-reference against existing `.experts/` inventory.** Read every domain `index.md` and list all expert files. Map each technology in the stack to the expert(s) that cover it. Mark technologies with no expert coverage as gaps. +6. **Score gaps by usage frequency and impact.** A framework used across every file (React, Express) scores higher than a dev-only tool used in one config file (Husky). Prioritize gaps that affect daily development decisions. +7. **Route library/framework experts to `languages/{lang}/libraries/`, not `.project/`.** When a gap is a language-specific framework or library (Next.js, Django, Spring Boot, Axum, etc.), place the expert under the relevant language's `libraries/` subfolder — e.g., `languages/typescript/libraries/nextjs.md`. This keeps framework knowledge co-located with the language it's written in and lets the language router load it alongside idioms and patterns. Reserve `.project/` for truly cross-cutting project-specific concerns that don't belong to a single language (CI/CD pipelines, infrastructure, project-specific workflows, multi-language prompt template conventions). +8. **Distinguish recommendation types.** Group into three categories: (a) **Populate stubs** — existing expert files that are placeholders; (b) **New project experts** — topics specific to this codebase's stack (frameworks, ORMs, CI/CD, etc.); (c) **General expert gaps** — topics that would benefit the general system (flag these but don't auto-create; they require broader applicability review). +9. **Output structured recommendations.** Each recommendation must include: filename, target domain (`languages/{lang}/libraries/` for language-specific frameworks, `.project/` for cross-cutting concerns), evidence (which files/deps triggered it), priority (high/medium/low), and a one-line expert purpose. +10. **Prioritize populating existing stubs over creating new experts.** Stubs represent already-identified knowledge gaps that the system is designed to hold. Filling them first maximizes coverage per effort. +11. **Update the target domain's `index.md` as experts are created.** After each expert is built, add it to the appropriate router's task clusters and file inventory. For library experts, update `languages/{lang}/libraries/index.md`. For cross-cutting experts, update `.project/index.md`. Keep routers current so the system can find new experts. +12. **Pair output with builder.md for handoff.** The analyzer identifies *what* to build; builder.md handles *how* to build it. Format recommendations so they can be directly fed into builder.md's Phase 1 scoping with the target domain pre-filled (`languages/{lang}/libraries/` or `.project/`). +13. **Scan for prompt template patterns.** Projects that use LLMs almost always have a prompt templating layer — and the implementation varies wildly. Scan for: LLM SDK imports (OpenAI, Anthropic, Azure OpenAI, LangChain, LlamaIndex, Semantic Kernel, Vercel AI SDK, etc.), prompt file conventions (a `prompts/` or `templates/` directory, `.prompt`, `.hbs`, `.jinja2`, `.mustache` files containing LLM instructions), string construction patterns (template literals, f-strings, or concatenation building system/user messages), and prompt management utilities (helper functions that assemble, format, or inject variables into prompts). When any of these signals are found, recommend a prompt template expert that documents: where templates live, which templating mechanism is used, how variables are injected, how system/user/assistant messages are constructed, and which LLM SDK the project calls. If the project uses a single language for LLM calls, place the expert under `languages/{lang}/libraries/prompt-templates.md`. If prompt construction spans multiple languages, place it under `.project/prompt-templates.md`. This expert pairs with `tools/prompt-engineer.md` — the general expert provides the design principles, the project expert provides the local conventions. +14. **Score prompt template gaps as high priority when LLM usage is core.** If the project's primary purpose involves LLM calls (an AI agent, a chatbot, a RAG pipeline, a prompt-driven workflow), the prompt template expert is high priority — it affects nearly every feature. If LLM calls are peripheral (e.g., a single summarization endpoint in a larger app), score it as medium. + +## patterns + +### Manifest scanning sequence + +``` +1. List root directory → identify project type +2. Read primary manifest: + - Node.js → package.json (dependencies, devDependencies, scripts) + - Rust → Cargo.toml (dependencies, features) + - Go → go.mod (require, module path) + - Python → pyproject.toml or requirements.txt + - Java → pom.xml or build.gradle + - C# → *.csproj (PackageReference) + - Ruby → Gemfile +3. Read secondary signals: + - CI/CD → .github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile + - Infra → Dockerfile, docker-compose.yml, terraform/, k8s/ + - Config → tsconfig.json, .eslintrc.*, vite.config.*, etc. +4. Scan src/ structure for framework conventions +5. Check for monorepo signals: workspaces, lerna.json, nx.json, turbo.json +``` + +### Prompt template scanning sequence + +``` +1. Check for LLM SDK dependencies in manifests: + - Node.js → openai, @anthropic-ai/sdk, @azure/openai, langchain, + llamaindex, @ai-sdk/*, semantic-kernel + - Python → openai, anthropic, langchain, llama-index, + semantic-kernel, guidance, promptflow + - C# → Azure.AI.OpenAI, Anthropic, Microsoft.SemanticKernel, + Microsoft.Extensions.AI + - Go → github.com/sashabaranov/go-openai, github.com/anthropics/... + - Rust → async-openai, anthropic-rs + +2. Scan for prompt file conventions: + - Directories: prompts/, templates/, agents/, instructions/ + - File types: *.prompt, *.txt, *.md, *.hbs, *.jinja2, *.mustache, + *.liquid containing LLM instructions + - Naming: *system*, *prompt*, *agent*, *instruction* in filenames + +3. Scan source code for prompt construction patterns: + - Template literals / f-strings building message content + - System/user/assistant role message arrays + - Section tag patterns: style markers + - Variable interpolation: {{var}}, {var}, ${var}, {{ var }} + - Prompt builder/formatter utility functions or classes + +4. Identify the prompt architecture: + - Storage: files on disk, inline in code, database, CMS + - Templating: native string interpolation, Handlebars, Jinja2, + Mustache, Liquid, custom + - Structure: section tags, markdown headers, XML tags, plain text + - Multi-turn: message array construction, conversation history mgmt + - Variables: how context is injected (retrieval, user input, state) + +5. Catalog findings for the project prompt template expert: + - SDK + client setup pattern + - Where templates live (path conventions) + - Templating mechanism + variable syntax + - Message construction pattern (system/user/assistant) + - Section/structure conventions used in prompts +``` + +### Coverage gap output template + +```markdown +## Expert Coverage Analysis + +### Tech Stack +| Category | Technology | Version | +|-------------|-----------------|----------| +| Language | TypeScript | 5.x | +| Framework | Next.js | 14.x | +| ORM | Prisma | 5.x | +| Testing | Vitest | 1.x | +| CI/CD | GitHub Actions | — | + +### Coverage Map +| Technology | Expert Coverage | Status | +|-----------------|------------------------------|--------| +| TypeScript | languages/typescript/*.md | ✅ Full | +| Git workflows | tools/git.md | ✅ Full | +| Prompt design | tools/prompt-engineer.md | ✅ General | +| Next.js | — | ❌ Gap | +| Prisma | — | ❌ Gap | +| Prompt templates | — | ❌ Gap (project-specific) | +| Vitest | — | ❌ Gap | +| GitHub Actions | — | ❌ Gap | + +### Recommendations +| # | File | Domain | Priority | Purpose | +|---|----------------------|---------------------------------|----------|--------------------------------------------| +| 1 | nextjs.md | languages/typescript/libraries/ | High | Next.js App Router patterns and conventions | +| 2 | prisma.md | languages/typescript/libraries/ | High | Prisma schema design, queries, migrations | +| 3 | prompt-templates.md | .project/ | High | Project prompt template conventions, SDK patterns, variable injection (pairs with tools/prompt-engineer.md) | +| 4 | vitest.md | languages/typescript/libraries/ | Medium | Vitest configuration and testing patterns | +| 5 | github-actions.md | .project/ | Medium | GitHub Actions workflow patterns (cross-cutting, not language-specific) | +``` + +### Builder.md handoff + +After generating recommendations, offer to start building: + +``` +To create any of these experts, I'll hand off to builder.md with the +scoping already pre-filled: + + "Create expert: {filename} in {target domain} — {purpose}. + Evidence: {manifest signals}. Priority: {level}." + +Which experts should I create? (Select numbers, or "all high priority") +``` + +## pitfalls + +- **Don't recommend experts for one-off dependencies.** A single `lodash` import or a `chalk` dependency doesn't warrant an expert. Focus on technologies that shape architectural decisions and daily workflows. +- **`devDependencies` don't always mean active use.** Many projects accumulate unused dev dependencies. Cross-reference with config files and import statements before recommending experts based on devDependencies alone. +- **Stubs are not coverage.** An expert file that exists but contains only a research prompt (stub) provides zero guidance. Count stubs as gaps when assessing coverage. +- **Prioritize ruthlessly in monorepos.** A monorepo with 50 packages and 20 technologies needs 4-6 high-impact experts, not 20. Focus on shared technologies that affect the most packages. +- **Don't confuse project-specific config with general expertise.** A project's custom webpack config doesn't need an expert — webpack itself might. Experts cover reusable knowledge, not project-specific setup. +- **Don't skip prompt template scanning because "it's just strings."** Prompt construction is often spread across utility functions, config files, and inline code with no obvious directory convention. Projects that use LLMs always have prompt patterns — they're just not always in a `prompts/` folder. Search SDK imports and message construction calls, not just file names. +- **Don't duplicate `tools/prompt-engineer.md` in the project expert.** The project prompt template expert captures *how this project* builds prompts (file locations, template syntax, SDK setup, variable injection). The general `prompt-engineer.md` expert covers *how to design prompts well*. The project expert should reference and pair with the general expert, not restate its principles. + +## instructions + +Use this expert when the developer wants to assess their project's technology stack and identify which micro-experts would be most valuable to create. + +**Trigger phrases:** "explore the codebase," "recommend experts," "analyze project," "audit experts," "expert coverage," "gap analysis," "what experts should I create," "scan my project." + +Pair with: `builder.md` for creating the recommended experts. The analyzer produces the roadmap; the builder executes it. Pair with: `tools/prompt-engineer.md` — when a project prompt template expert is created in `.project/`, it should declare `Pair with: tools/prompt-engineer.md` so the general prompt design principles are loaded alongside the project-specific conventions. + +## research + +Deep Research prompt: + +"Write a meta-expert for scanning software project codebases and recommending micro-experts to create. Cover: manifest file scanning strategies (package.json, Cargo.toml, go.mod, pyproject.toml, pom.xml, *.csproj, Gemfile), directory structure analysis for framework detection, config file signals for tooling identification, tech stack cataloging methodology, coverage gap analysis against an existing expert inventory, recommendation prioritization (usage frequency, architectural impact, daily development relevance), structured output formats for recommendations, handoff protocol to an expert-building workflow, and common analysis pitfalls (one-off deps, unused devDependencies, monorepo sprawl, stubs vs coverage)." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/app-distribution-packaging-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/app-distribution-packaging-ts.md new file mode 100644 index 000000000..c08dc336d --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/app-distribution-packaging-ts.md @@ -0,0 +1,190 @@ +# app-distribution-packaging-ts + +## purpose + +Bridges Slack App Directory distribution and Teams app packaging / Admin Center publishing for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack App Directory → Teams App Store (Partner Center).** Slack apps are listed in the Slack App Directory for public distribution. Teams apps are published to the Microsoft Teams App Store via Partner Center. The review and submission process is completely different — Partner Center requires a Microsoft Partner Network account and compliance with Teams store validation policies. [learn.microsoft.com -- Publish to store](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/publish) +2. **Slack OAuth install flow → Azure Bot registration (no per-workspace tokens).** Slack apps use OAuth to install into each workspace, generating per-workspace `xoxb-` tokens stored in an `InstallationStore`. Teams bots use Azure Bot Framework credentials (`CLIENT_ID`/`CLIENT_SECRET`) that work across all tenants. There are no per-workspace tokens to manage. Delete `InstallationStore` and all OAuth install flow code. [learn.microsoft.com -- Bot registration](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) +3. **Slack `InstallationStore` → conversation reference storage.** Slack's `InstallationStore` persists tokens per workspace for API calls. Teams doesn't need per-workspace tokens, but you still need to store conversation references for proactive messaging. Replace `InstallationStore` with a conversation reference store keyed by `conversationId`. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +4. **Slack org-level install → Teams Admin Center tenant-wide deployment.** Slack Enterprise Grid supports org-level app installation. In Teams, tenant-wide deployment is done via the Teams Admin Center by an IT admin: Manage Apps → Upload/Approve → Deploy to users/groups. No code changes needed — the admin controls distribution. [learn.microsoft.com -- Admin Center](https://learn.microsoft.com/en-us/microsoftteams/manage-apps) +5. **Development install → Teams sideloading.** Slack development apps are installed via the app's manage page or OAuth URL. Teams development apps are sideloaded: upload the app package (ZIP with manifest + icons) directly into Teams. Sideloading must be enabled by the tenant admin. [learn.microsoft.com -- Sideloading](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) +6. **Agents Toolkit simplifies packaging, provisioning, and deployment.** Agents Toolkit (VS Code extension or CLI `atk`) automates: Azure resource provisioning, app package generation, sideloading, and publishing. It replaces the manual Azure Portal + zip file workflow. Use `atk package` to generate the app package and `atk publish` to submit. [learn.microsoft.com -- Agents Toolkit](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/teams-toolkit-fundamentals) +7. **Multi-tenant Slack app → Azure AD multi-tenant app registration.** Slack multi-workspace apps use the App Directory + OAuth per workspace. Teams multi-tenant bots use a single Azure AD app registration with `signInAudience: "AzureADMultipleOrgs"`. Any tenant can install the bot without workspace-specific OAuth. [learn.microsoft.com -- Multi-tenant](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-authentication-basics) +8. **Slack app manifest (`manifest.json`) → Teams app manifest (`manifest.json` in app package).** Both platforms use JSON manifests but with completely different schemas. Slack's manifest includes OAuth scopes, event subscriptions, slash commands. Teams manifest includes `bots`, `composeExtensions`, `staticTabs`, `webApplicationInfo`, `validDomains`. No automatic conversion exists. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) +9. **Slack app icons (512x512 + workspace-specific) → Teams icons (color 192x192 + outline 32x32).** Teams requires exactly two icon files in the app package: a full-color icon (192x192 PNG) and an outline/monochrome icon (32x32 PNG with transparent background). The outline icon is used in the Teams activity bar. [learn.microsoft.com -- App icons](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#icons) +10. **Slack app review (hours-days) vs Teams store review (1-2 weeks).** Slack's App Directory review is relatively fast. Teams App Store review via Partner Center is more rigorous and can take 1-2 weeks. Plan for revision cycles — common rejection reasons include missing privacy policy URL, incomplete manifest, and accessibility issues. [learn.microsoft.com -- Store validation](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/prepare/teams-store-validation-guidelines) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map Teams manifest to Slack app manifest, and Teams Admin Center publishing to Slack App Directory submission. Azure Bot registration credentials map to Slack OAuth install flow with `InstallationStore` for per-workspace tokens. Teams sideloading maps to Slack development install via OAuth URL. The Teams color/outline icon pair maps to Slack's single 512x512 app icon. Azure AD multi-tenant registration maps to Slack App Directory multi-workspace distribution with per-workspace OAuth. + +## patterns + +### InstallationStore removal + conversation reference storage + +**Slack (before):** + +```typescript +import { App, Installation, InstallationQuery } from "@slack/bolt"; + +// InstallationStore — persist per-workspace tokens +const installationStore = { + storeInstallation: async (installation: Installation) => { + const teamId = installation.team?.id ?? installation.enterprise?.id; + await db.put(`installation:${teamId}`, JSON.stringify(installation)); + }, + fetchInstallation: async (query: InstallationQuery) => { + const teamId = query.teamId ?? query.enterpriseId; + const data = await db.get(`installation:${teamId}`); + return JSON.parse(data) as Installation; + }, + deleteInstallation: async (query: InstallationQuery) => { + const teamId = query.teamId ?? query.enterpriseId; + await db.delete(`installation:${teamId}`); + }, +}; + +const app = new App({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, + clientId: process.env.SLACK_CLIENT_ID!, + clientSecret: process.env.SLACK_CLIENT_SECRET!, + stateSecret: process.env.SLACK_STATE_SECRET!, + installationStore, + scopes: ["chat:write", "commands", "channels:history"], +}); + +// Use workspace-specific token for API calls +app.message(/hello/i, async ({ say, client }) => { + // client automatically uses the workspace's xoxb token + await say("Hello!"); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +// No InstallationStore needed — single credential set works for all tenants +const app = new App({ + clientId: process.env.CLIENT_ID, // Azure Bot app ID + clientSecret: process.env.CLIENT_SECRET, // Azure Bot secret + tenantId: process.env.TENANT_ID, // or "common" for multi-tenant + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store conversation references instead of installations +// Needed for proactive messaging (the only thing that replaced InstallationStore's purpose) +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity, send }) => { + // Persist conversation reference for future proactive messaging + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + tenantId: activity.channelData?.tenant?.id ?? "", + }); + await send("Bot installed! I'm ready to help."); +}); + +app.on("install.remove", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.delete(convId); +}); + +app.message(/hello/i, async ({ send }) => { + // No workspace token lookup needed — just send + await send("Hello!"); +}); + +app.start(3978); +``` + +### App Directory → Admin Center deployment + +**Slack** — submit app to Slack App Directory via api.slack.com dashboard. Users install via the directory. + +**Teams** — multiple distribution paths: + +```shell +# Option 1: Sideload for development +# Build the app package (manifest.json + icons in a ZIP) +atk package --env dev -i false + +# Upload to Teams: +# Teams → Apps → Manage your apps → Upload a custom app + +# Option 2: Submit to organization's app catalog +atk publish --env staging +# IT admin approves in Teams Admin Center → Manage Apps + +# Option 3: Submit to public Teams App Store (Partner Center) +# 1. Create Partner Center account +# 2. Submit app package for review +# 3. Review takes 1-2 weeks +# 4. Once approved, appears in Teams App Store + +# Option 4: Tenant-wide deployment (admin pushes to all users) +# Teams Admin Center → Manage Apps → find app → Assign to users/groups +# No code changes — purely admin configuration +``` + +**Teams app package structure:** + +``` +my-teams-bot.zip +├── manifest.json # Teams-specific manifest (not Slack's) +├── color.png # 192x192 full-color icon +└── outline.png # 32x32 monochrome outline icon +``` + +### Distribution model mapping table + +| Slack Distribution | Teams Equivalent | Notes | +|---|---|---| +| App Directory (public listing) | Teams App Store via Partner Center | Requires partner account; 1-2 week review | +| OAuth install flow (per-workspace) | Azure Bot registration (global) | No per-workspace tokens | +| `InstallationStore` | Conversation reference store | Only for proactive messaging | +| Org-level install (Enterprise Grid) | Teams Admin Center tenant-wide deploy | Admin pushes to users/groups | +| Development install (OAuth URL) | Sideloading (upload ZIP) | Admin must enable sideloading | +| `manifest.json` (Slack schema) | `manifest.json` (Teams schema) | Completely different schemas | +| App icon (512x512) | Color (192x192) + Outline (32x32) | Two icons required | +| OAuth scopes (`chat:write`, etc.) | Azure AD permissions + RSC | Different permission model | +| Multi-workspace (App Directory) | Multi-tenant (Azure AD) | `signInAudience: "AzureADMultipleOrgs"` | + +## pitfalls + +- **Trying to port the InstallationStore**: Teams does not need per-workspace token storage. Developers who port `InstallationStore` logic create unnecessary complexity. Delete it and use conversation reference storage only for proactive messaging. +- **Sideloading disabled by default in many orgs**: IT admins may have disabled sideloading. If the developer can't upload the app package, they need to request sideloading permission from their Teams admin. This is a common blocker during development. +- **Partner Center account setup takes time**: Publishing to the Teams App Store requires a Microsoft Partner Network account. Account verification can take days. Start the Partner Center registration early in the migration timeline. +- **Icon format rejection**: Teams requires exactly two PNG icons with specific dimensions. The outline icon must have a transparent background. Submitting icons in the wrong format or size causes app package validation failure. +- **Multi-tenant vs single-tenant confusion**: Slack apps are inherently multi-workspace when listed in the App Directory. Teams apps must explicitly set multi-tenant in the Azure AD app registration. A single-tenant registration only works in the developer's own organization. +- **OAuth scopes → RSC permissions**: Slack OAuth scopes (`channels:history`, `chat:write`) have no direct mapping to Azure AD permissions. Teams uses a combination of Azure AD API permissions and Resource-Specific Consent (RSC) permissions declared in the manifest. This is the most conceptually different part of the migration. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/publish +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload +- https://learn.microsoft.com/en-us/microsoftteams/manage-apps +- https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/teams-toolkit-fundamentals +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/prepare/teams-store-validation-guidelines +- https://github.com/microsoft/teams.ts +- https://api.slack.com/distribution — Slack app distribution + +## instructions + +Use this expert when adding cross-platform support in either direction for app distribution and packaging. It covers: Slack App Directory bridged to Teams App Store (Partner Center), OAuth install flow vs Azure Bot registration, InstallationStore vs conversation reference storage, org-level deployment via Teams Admin Center, sideloading for development, Agents Toolkit for packaging, multi-tenant Azure AD registration, icon requirements, store review timelines, and reverse mapping from Teams manifest/Admin Center back to Slack app manifest and App Directory submission. Pair with `identity-oauth-bridge-ts.md` for the identity/OAuth model change, `../teams/runtime.manifest-ts.md` for Teams manifest creation, and `../teams/runtime.proactive-messaging-ts.md` for conversation reference storage patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack App Directory distribution and Microsoft Teams app packaging / Admin Center publishing in either direction. Cover: App Directory vs Teams App Store (Partner Center), OAuth install flow vs Azure Bot registration, InstallationStore vs conversation reference storage, org-level install vs Teams Admin Center, sideloading, Agents Toolkit packaging, multi-tenant Azure AD app registration, icon requirements, manifest schema differences, OAuth scope to RSC mapping, store review timeline, and reverse mapping from Teams manifest/publishing back to Slack app manifest and App Directory submission. Include code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/channel-ops-graph-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/channel-ops-graph-ts.md new file mode 100644 index 000000000..0adc6c30f --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/channel-ops-graph-ts.md @@ -0,0 +1,267 @@ +# channel-ops-graph-ts + +## purpose + +Bridges Slack channel operations (conversations.*) and Teams channel management via Microsoft Graph for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `conversations.create` → Graph `POST /teams/{team-id}/channels`.** Slack creates channels in a flat workspace namespace. Teams channels always belong to a specific team — you must know the `team-id` first. The request body includes `displayName`, `description`, and `membershipType` (standard, private, shared). [learn.microsoft.com -- Create channel](https://learn.microsoft.com/en-us/graph/api/channel-post) +2. **Slack `conversations.archive` → no true archive in Teams.** Teams has no channel archive API. Workarounds: (a) delete the channel (destructive, 30-day soft delete), (b) rename the channel with a `[ARCHIVED]` prefix, (c) remove all members except owners, (d) for the entire team, use `POST /teams/{team-id}/archive`. Individual channel archival is not supported. [learn.microsoft.com -- Archive team](https://learn.microsoft.com/en-us/graph/api/team-archive) +3. **Slack `conversations.invite` → Graph `POST /teams/{team-id}/channels/{channel-id}/members`.** The request body must include the user's Azure AD Object ID (`@odata.type: '#microsoft.graph.aadUserConversationMember'`) and a `roles` array (`['owner']` or `[]` for member). Private channel membership is managed separately from standard channels. [learn.microsoft.com -- Add channel member](https://learn.microsoft.com/en-us/graph/api/channel-post-members) +4. **Slack `conversations.kick` → Graph `DELETE /teams/{team-id}/channels/{channel-id}/members/{membership-id}`.** You must first resolve the `membership-id` by listing channel members (`GET /teams/{team-id}/channels/{channel-id}/members`) and finding the member by their Azure AD Object ID. You cannot delete by user ID directly. [learn.microsoft.com -- Remove member](https://learn.microsoft.com/en-us/graph/api/channel-delete-members) +5. **Slack `conversations.setTopic` → Graph `PATCH /teams/{team-id}/channels/{channel-id}` with `description`.** Slack channels have a separate `topic` field. Teams channels use the `description` field as the closest equivalent. The channel name is updated via the `displayName` field. [learn.microsoft.com -- Update channel](https://learn.microsoft.com/en-us/graph/api/channel-patch) +6. **All channel operations require a `team-id`.** Slack has a flat channel namespace (every channel has a globally-unique `C-ID`). Teams channels are nested under teams. Most operations need both `team-id` and `channel-id`. Resolve team IDs via `GET /me/joinedTeams` or `GET /groups` with Teams filter. [learn.microsoft.com -- List joined teams](https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams) +7. **Channel name restrictions differ from Slack.** Teams channel names cannot contain: `~ # % & * { } / \ : < > ? + | ' "`. Maximum length is 50 characters (Slack allows 80). Channel names must be unique within a team. Validate and sanitize names during migration. [learn.microsoft.com -- Channel limits](https://learn.microsoft.com/en-us/microsoftteams/limits-specifications-teams) +8. **Graph API requires application or delegated permissions.** Channel operations need `Channel.Create`, `ChannelMember.ReadWrite.All`, `Channel.Delete.All` (application permissions) or equivalent delegated permissions. These require Azure AD admin consent. Slack's bot token scopes (`channels:manage`, `channels:write.invites`) have no direct Azure AD equivalent. [learn.microsoft.com -- Graph permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) +9. **Slack `conversations.list` → Graph `GET /teams/{team-id}/channels`.** List all channels in a team. For listing channels across teams, iterate over `GET /me/joinedTeams` first, then list channels per team. There is no single API to list all channels across all teams (unlike Slack's flat listing). [learn.microsoft.com -- List channels](https://learn.microsoft.com/en-us/graph/api/channel-list) +10. **Private channels have separate membership management.** Slack private channels (`is_private: true`) map to Teams private channels (`membershipType: 'private'`). Private channel members are managed via the channel members API, not the team membership. Adding a user to the team does NOT add them to private channels — you must add them to both. [learn.microsoft.com -- Private channels](https://learn.microsoft.com/en-us/microsoftteams/private-channels) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map Graph API channel operations to Slack's `conversations.*` API methods. `POST /teams/{team-id}/channels` maps to `conversations.create`. `POST /channels/{id}/members` maps to `conversations.invite`. `DELETE /channels/{id}/members/{id}` maps to `conversations.kick`. `PATCH /channels/{id}` with `description` maps to `conversations.setTopic`. Note that Slack has a flat channel namespace (no team-id required) and supports true channel archiving via `conversations.archive`. + +## patterns + +### Create channel + invite members + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/create-channel", async ({ ack, command, client }) => { + await ack(); + const [name, ...memberIds] = command.text.split(" "); + + // Create channel in flat namespace + const channel = await client.conversations.create({ + name: name.toLowerCase().replace(/\s+/g, "-"), + is_private: false, + }); + + // Invite members by Slack user ID + if (memberIds.length > 0) { + await client.conversations.invite({ + channel: channel.channel!.id!, + users: memberIds.join(","), // comma-separated U-IDs + }); + } + + await client.chat.postMessage({ + channel: command.channel_id, + text: `Channel <#${channel.channel!.id}> created!`, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { Client } from "@microsoft/microsoft-graph-client"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Initialize Graph client (use app-only auth in production) +function getGraphClient(token: string): Client { + return Client.init({ + authProvider: (done) => done(null, token), + }); +} + +app.message(/^\/?create-channel (.+)$/i, async ({ send, activity }) => { + const args = activity.text?.replace(/^\/?create-channel\s+/i, "").split(" ") ?? []; + const [rawName, ...memberAadIds] = args; + + // Sanitize channel name for Teams restrictions + const channelName = rawName + .replace(/[~#%&*{}\/\\:<>?+|'"]/g, "") + .substring(0, 50); + + // Teams channels require a team-id (no flat namespace) + const teamId = activity.channelData?.team?.id; + if (!teamId) { + await send("This command must be run in a team context."); + return; + } + + const graphToken = await getAppOnlyToken(); + const graph = getGraphClient(graphToken); + + // Create channel under the team + const channel = await graph.api(`/teams/${teamId}/channels`).post({ + displayName: channelName, + description: `Created by bot on ${new Date().toISOString()}`, + membershipType: "standard", + }); + + // Invite members by Azure AD Object ID (not Slack U-ID) + for (const aadId of memberAadIds) { + await graph.api(`/teams/${teamId}/channels/${channel.id}/members`).post({ + "@odata.type": "#microsoft.graph.aadUserConversationMember", + "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${aadId}')`, + roles: [], // empty = member, ['owner'] = owner + }); + } + + await send(`Channel **${channelName}** created with ${memberAadIds.length} members.`); +}); + +async function getAppOnlyToken(): Promise { + // Use @azure/identity ConfidentialClientApplication for production + return "..."; +} + +app.start(3978); +``` + +### Set topic + archive channel + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/set-topic", async ({ ack, command, client }) => { + await ack(); + await client.conversations.setTopic({ + channel: command.channel_id, + topic: command.text, + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: `Topic updated to: ${command.text}`, + }); +}); + +app.command("/archive-channel", async ({ ack, command, client }) => { + await ack(); + await client.conversations.archive({ + channel: command.channel_id, + }); + // Channel is now archived — no more messages can be posted +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { Client } from "@microsoft/microsoft-graph-client"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +function getGraphClient(token: string): Client { + return Client.init({ authProvider: (done) => done(null, token) }); +} + +// Set channel description (closest to Slack topic) +app.message(/^\/?set-topic (.+)$/i, async ({ send, activity }) => { + const topic = activity.text?.replace(/^\/?set-topic\s+/i, "") ?? ""; + const teamId = activity.channelData?.team?.id; + const channelId = activity.channelData?.channel?.id; + + if (!teamId || !channelId) { + await send("This command must be run in a team channel."); + return; + } + + const graph = getGraphClient(await getAppOnlyToken()); + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + description: topic, + }); + + await send(`Channel description updated to: ${topic}`); +}); + +// Archive channel — no direct equivalent, rename with prefix +app.message(/^\/?archive-channel$/i, async ({ send, activity }) => { + const teamId = activity.channelData?.team?.id; + const channelId = activity.channelData?.channel?.id; + + if (!teamId || !channelId) { + await send("This command must be run in a team channel."); + return; + } + + const graph = getGraphClient(await getAppOnlyToken()); + + // Get current channel name + const channel = await graph.api(`/teams/${teamId}/channels/${channelId}`).get(); + + // Rename with archive prefix (best available workaround) + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + displayName: `[ARCHIVED] ${channel.displayName}`.substring(0, 50), + description: `Archived on ${new Date().toISOString()}. ${channel.description ?? ""}`, + }); + + await send("Channel marked as archived. Note: Teams does not support true channel archival."); +}); + +async function getAppOnlyToken(): Promise { + return "..."; +} + +app.start(3978); +``` + +### Channel operation mapping table + +| Slack API | Graph API Equivalent | Notes | +|---|---|---| +| `conversations.create({ name })` | `POST /teams/{team-id}/channels` | Must specify team-id | +| `conversations.archive({ channel })` | *(no equivalent)* | Rename with prefix, or delete | +| `conversations.unarchive({ channel })` | *(no equivalent)* | Rename back | +| `conversations.invite({ channel, users })` | `POST /teams/{team-id}/channels/{id}/members` | One member per call; needs AAD Object ID | +| `conversations.kick({ channel, user })` | `DELETE /channels/{id}/members/{membership-id}` | Must resolve membership-id first | +| `conversations.setTopic({ channel, topic })` | `PATCH /channels/{id}` with `description` | Topic → description | +| `conversations.rename({ channel, name })` | `PATCH /channels/{id}` with `displayName` | 50 char limit | +| `conversations.list()` | `GET /teams/{team-id}/channels` | Per-team, not workspace-wide | +| `conversations.info({ channel })` | `GET /teams/{team-id}/channels/{id}` | Needs team-id | +| `conversations.members({ channel })` | `GET /teams/{team-id}/channels/{id}/members` | Returns AAD user objects | + +## pitfalls + +- **No flat channel namespace**: Slack's `C-ID` identifies a channel globally. Teams requires both `team-id` and `channel-id` for most operations. Bots must resolve or store the team context from `activity.channelData.team.id`. +- **Channel name validation**: Teams rejects names with special characters that Slack allows. Always sanitize channel names before creating. The `#` character — commonly used in Slack — is not allowed in Teams channel names. +- **Membership ID resolution for kicks**: You cannot remove a member by Azure AD Object ID alone. First list members, find the matching `conversationMember.id`, then delete by that membership ID. This is a two-API-call operation. +- **No true channel archive**: Slack's archive makes a channel read-only while preserving it. Teams has no equivalent. The rename-with-prefix workaround doesn't prevent new messages. True read-only requires deleting the channel (which has a 30-day recovery window). +- **Private channel membership is separate**: Adding a user to a team does NOT automatically add them to private channels. You must explicitly add them to each private channel. This differs from Slack where inviting to a private channel only requires the channel invite API. +- **Graph API rate limits**: Graph API has its own throttling (separate from Bot Framework). Bulk channel operations (creating many channels, inviting many users) should include retry logic with exponential backoff on HTTP 429 responses. +- **Admin consent required**: Application-level Graph permissions (`Channel.Create`, `ChannelMember.ReadWrite.All`) require Azure AD admin consent. This is a deployment-time concern — the bot code may work in dev but fail in production if admin consent hasn't been granted. + +## references + +- https://learn.microsoft.com/en-us/graph/api/channel-post +- https://learn.microsoft.com/en-us/graph/api/channel-post-members +- https://learn.microsoft.com/en-us/graph/api/channel-delete-members +- https://learn.microsoft.com/en-us/graph/api/channel-patch +- https://learn.microsoft.com/en-us/graph/api/team-archive +- https://learn.microsoft.com/en-us/graph/api/channel-list +- https://learn.microsoft.com/en-us/microsoftteams/limits-specifications-teams +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/conversations.create — Slack conversations API + +## instructions + +Use this expert when adding cross-platform support in either direction for channel management operations. It covers: Slack `conversations.*` bridged to Graph API channel endpoints, `conversations.archive` workarounds in Teams, `conversations.invite` bridged to Graph member addition, `conversations.kick` with membership ID resolution, `conversations.setTopic` bridged to channel description update, team-id requirement, channel name restrictions, Graph API permission requirements, and reverse mapping from Graph channel operations back to Slack `conversations.*` methods. Pair with `../teams/graph.usergraph-appgraph-ts.md` for Graph API authentication, `identity-oauth-bridge-ts.md` for user ID mapping (Slack U-ID to AAD Object ID), and `rate-limiting-resilience-ts.md` for Graph API throttling patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack channel management operations (conversations.create, conversations.archive, conversations.invite, conversations.kick, conversations.setTopic, conversations.list) and Microsoft Teams channel management via the Graph API in either direction. Cover: team-id requirement, channel name restrictions, private channel membership, the lack of channel archive API in Teams, membership ID resolution for removal, Graph API permissions, rate limiting, and reverse mapping from Graph operations back to Slack conversations.* methods. Include TypeScript code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/commands-slash-text-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/commands-slash-text-ts.md new file mode 100644 index 000000000..d7441bb6f --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/commands-slash-text-ts.md @@ -0,0 +1,410 @@ +# commands-slash-text-ts + +## purpose + +Bridges Slack slash commands and Teams text commands / message extensions for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Teams bots do **not** have a native slash command system equivalent to Slack's `app.command('/name')`. Slack slash commands must be reimplemented using one of three Teams patterns: text pattern matching, messaging extensions, or manifest command hints. [learn.microsoft.com -- Bots in Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/what-are-bots) +2. The most direct migration path is **text pattern matching** with `app.message(regex)` in the Teams SDK. Map `app.command('/help')` to `app.message(/^\/?help$/i)`. The leading `/?` makes the slash optional so users can type either "help" or "/help". [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. Remove all `ack()` calls when migrating to Teams. Teams handlers do not require acknowledgement -- simply process the request and respond. The `ack` concept does not exist in the Teams SDK. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +4. Replace Slack's `respond()` (response_url) and `say()` with the Teams context methods `send()` (new message) and `reply()` (threaded reply). There is no Teams equivalent of Slack's ephemeral response -- all bot messages are visible to participants. [learn.microsoft.com -- Send proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +5. Slack's `trigger_id` for opening modals has no direct Teams equivalent. Instead, send an Adaptive Card with form inputs inline, or use a Task Module (dialog) opened via `dialog.open` handler. Task modules do not require a trigger_id -- they are opened by card actions or link unfurling. [learn.microsoft.com -- Task modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules) +6. For command **discoverability**, add entries to the `commands` array in the manifest's `bots` section. These appear as suggestions when users type in the bot's compose box. They are UI hints only -- the bot still receives the text as a regular message. [learn.microsoft.com -- Bot commands](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) +7. For a richer command UX, use **messaging extensions** (`composeExtensions` in manifest). Search-based extensions let users query and insert results; action-based extensions open a task module form. These replace complex slash commands that opened modals or returned structured data. [learn.microsoft.com -- Message extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions) +8. Slack's `command.text` (the argument string) maps to parsing `activity.text` in Teams. Strip the bot @mention prefix first (set `activity.mentions.stripText: true` in App options), then parse the remaining text for arguments. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. Slack's `command.user_id` maps to `activity.from.aadObjectId` (Azure AD Object ID) in Teams. Slack's `command.channel_id` maps to `activity.conversation.id`. These IDs have completely different formats and are not interchangeable. [learn.microsoft.com -- Activity schema](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +10. In Teams channels, bots only receive messages when @mentioned (unless configured otherwise via RSC permissions). Slash commands in Slack work without mention. Account for this UX difference by instructing users to @mention the bot or by scoping command bots to personal chat where every message is delivered. [learn.microsoft.com -- Channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) + +## patterns + +### Migrating a Slack slash command to Teams text pattern matching + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/status", async ({ ack, command, respond }) => { + await ack("Checking status..."); + const status = await getSystemStatus(); + await respond({ + response_type: "in_channel", + text: `System status: ${status}`, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// No ack() needed. Regex makes the leading slash optional. +app.message(/^\/?status$/i, async ({ send }) => { + const status = await getSystemStatus(); + // No ephemeral option -- all messages are visible + await send(`System status: ${status}`); +}); + +async function getSystemStatus(): Promise { + return "All systems operational"; +} + +app.start(3978); +``` + +### Migrating a command that opened a modal to a Teams Adaptive Card form + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/ticket", async ({ ack, command, client }) => { + await ack(); + await client.views.open({ + trigger_id: command.trigger_id, + view: { + type: "modal", + callback_id: "ticket_modal", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Create" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Title" }, + element: { type: "plain_text_input", action_id: "title_input" }, + }, + ], + }, + }); +}); + +app.view("ticket_modal", async ({ ack, view, client }) => { + const title = view.state.values.title_block.title_input.value!; + await ack(); + await client.chat.postMessage({ + channel: "#tickets", + text: `New ticket: ${title}`, + }); +}); +``` + +**Teams (after) -- Adaptive Card inline form replaces modal:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// User types "ticket" or "/ticket" to get the form card +app.message(/^\/?ticket$/i, async ({ send }) => { + await send({ + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Create Ticket", weight: "Bolder", size: "Large" }, + { + type: "Input.Text", + id: "ticketTitle", + label: "Title", + placeholder: "Describe the issue", + isRequired: true, + errorMessage: "Title is required", + }, + { + type: "Input.ChoiceSet", + id: "ticketPriority", + label: "Priority", + value: "medium", + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Create", + data: { action: "createTicket" }, + }, + ], + }, + }, + ], + }); +}); + +// Handle the card form submission (replaces app.view handler) +app.on("card.action", async ({ activity, send }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.action === "createTicket") { + const title = data.ticketTitle; + const priority = data.ticketPriority; + await send(`Ticket created: ${title} [${priority}]`); + return { status: 200 }; + } +}); + +app.start(3978); +``` + +### Migrating a data-lookup command to a search-based message extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// User types: /lookup serverName +app.command("/lookup", async ({ ack, command, respond }) => { + await ack(); + const query = command.text; + const results = await searchServers(query); + if (results.length === 0) { + await respond({ response_type: "ephemeral", text: "No results found." }); + return; + } + await respond({ + response_type: "ephemeral", + blocks: results.map((r) => ({ + type: "section", + text: { type: "mrkdwn", text: `*${r.name}*\nStatus: ${r.status} | IP: ${r.ip}` }, + })), + }); +}); +``` + +**Teams (after) — search-based message extension:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Search-based message extension replaces /lookup +// Triggered from compose box or command bar in Teams +app.on("message.ext.query" as any, async ({ activity }) => { + const query = activity.value?.queryOptions?.searchText ?? ""; + const results = await searchServers(query); + + return { + status: 200, + body: { + composeExtension: { + type: "result", + attachmentLayout: "list", + attachments: results.map((r) => ({ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: r.name, weight: "Bolder" }, + { type: "TextBlock", text: `Status: ${r.status} | IP: ${r.ip}`, isSubtle: true }, + ], + }, + preview: { + contentType: "application/vnd.microsoft.card.thumbnail", + content: { title: r.name, text: `${r.status} — ${r.ip}` }, + }, + })), + }, + }, + }; +}); + +async function searchServers(query: string) { + return [{ name: "web-prod-01", status: "healthy", ip: "10.0.1.5" }]; +} + +app.start(3978); +``` + +**Manifest `composeExtensions` config (required for message extensions):** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "lookupServer", + "type": "query", + "title": "Lookup Server", + "description": "Search for servers by name", + "initialRun": false, + "parameters": [ + { + "name": "searchText", + "title": "Server name", + "description": "Search for a server", + "inputType": "text" + } + ] + } + ] + } + ] +} +``` + +### Adding manifest commands for discoverability + +```json +{ + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { + "title": "status", + "description": "Check system status" + }, + { + "title": "ticket", + "description": "Create a new support ticket" + }, + { + "title": "help", + "description": "Show available commands" + } + ] + } + ] +} +``` + +**Command mapping reference table:** + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.command('/help', ...)` | `app.message(/^\/?help$/i, ...)` | Text matching; no ack needed | +| `ack()` / `ack(text)` | *(remove)* | Teams has no ack concept | +| `respond({ response_type: "in_channel" })` | `send(text)` | All Teams messages are visible | +| `respond({ response_type: "ephemeral" })` | *(no equivalent)* | Redesign as personal chat or card | +| `command.trigger_id` + `views.open()` | Adaptive Card form or `dialog.open` | No trigger_id in Teams | +| `command.text` | `activity.text` (after stripping @mention) | Parse arguments from message text | +| `command.user_id` (U-ID) | `activity.from.aadObjectId` (AAD GUID) | Different ID format | +| `command.channel_id` (C-ID) | `activity.conversation.id` | Different ID format | +| `command.response_url` | `send()` / `reply()` | Direct methods, no URL-based responses | +| Manifest: Slack app dashboard | Manifest: `bots[].commands[]` | JSON file instead of web UI | + +### Best practice: text matching + manifest commands together (Y1) + +Use **both** text pattern matching and manifest bot commands for the best UX. Manifest commands give discoverability (users see them in the command menu); text matching ensures the bot responds to both `/weather` and `weather` so users migrating from Slack don't retrain muscle memory. + +```typescript +// Accept both "/weather" and "weather" — regex makes slash optional +app.message(/^\/?weather$/i, async ({ send }) => { + const weather = await getWeather(); + await send(`Current weather: ${weather}`); +}); +``` + +**Manifest (add commands for discoverability):** + +```json +{ + "bots": [{ + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { "title": "weather", "description": "Check the current weather" }, + { "title": "status", "description": "Check system status" }, + { "title": "help", "description": "Show available commands" } + ] + }] +} +``` + +**Don't:** Create a message extension for every slash command. Reserve extensions for commands that benefit from rich search results or task module UI. + +**Reverse (Teams → Slack):** Register commands via `app.command("/name", handler)` with `await ack()`. Configure in the Slack app dashboard. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, map `app.message(regex)` to `app.command('/name')`, add `ack()` calls, and convert Adaptive Card forms to Block Kit modals. Key reverse mappings: +- `app.message(/^\/?name$/i, ...)` → `app.command('/name', ...)` with `await ack()` at the top +- `send(text)` → `respond({ response_type: 'in_channel', text })` or `say(text)` +- `reply(text)` → `say({ text, thread_ts: message.ts })` +- Adaptive Card inline form → `views.open(trigger_id, view)` with Block Kit modal +- `app.on('card.action', ...)` with `data.action` routing → `app.view('callback_id', ...)` for modal submissions, `app.action('action_id', ...)` for button clicks +- Manifest `bots[].commands[]` → Slack App Dashboard slash command configuration +- `activity.from.aadObjectId` → `command.user_id` (requires ID mapping table) +- `activity.text` (after stripping @mention) → `command.text` (clean argument string) +- Message extensions (search-based) → slash commands returning ephemeral blocks, or external data source selects +- All visible messages → consider which should be `response_type: 'ephemeral'` for Slack's richer privacy model + +## pitfalls + +- **Expecting slash command UX in Teams**: Teams users do not get the same discoverable `/command` experience. Set expectations that commands are triggered by typing text or using the bot commands menu. +- **Forgetting to remove `ack()`**: Leaving `ack()` calls in migrated code causes runtime errors since the Teams context object has no `ack` method. +- **Not handling @mention prefix**: In Teams channels, `activity.text` includes the @mention text (e.g., `BotName status`). Set `activity.mentions.stripText: true` in App options or strip manually before matching. +- **Relying on ephemeral responses**: Slack commands can respond ephemerally. Teams has no ephemeral messages. Redesign private responses as personal (1:1) chat messages or use Adaptive Cards that only the acting user sees after refresh. +- **Ignoring the personal vs channel distinction**: In Slack, slash commands work identically in channels and DMs. In Teams, channel bots require @mention. Consider scoping command-heavy bots to personal chat for a smoother UX. +- **Missing manifest commands**: Without `commands` in the manifest, users have no way to discover what the bot supports. Always add command hints for discoverability. +- **Complex argument parsing**: Slack's `command.text` arrives as a clean string after the command name. In Teams, you must parse `activity.text` which may include the bot mention, extra whitespace, and varied formatting. +- **Missing `composeExtensions` in manifest**: Message extensions (search-based or action-based) require a `composeExtensions` entry in the Teams manifest. Without it, the extension never appears in the compose box or command bar. This is the most common reason message extensions silently fail to load. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/what-are-bots +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations +- https://github.com/microsoft/teams.ts +- https://slack.dev/bolt-js/concepts/commands +- https://api.slack.com/interactivity/slash-commands + +## instructions + +This expert covers bridging Slack slash commands and Teams text commands / message extensions. Use it when adding cross-platform support in either direction: converting `app.command()` handlers to Teams `app.message()` with regex patterns, or converting Teams text handlers back to Slack slash commands with `ack()` calls. It covers the three Teams alternatives to slash commands (text matching, messaging extensions, manifest commands), response pattern bridging (`respond`/`say` ↔ `send`/`reply`), modal/form bridging (`trigger_id` + `views.open` ↔ Adaptive Card forms / Task Modules), command payload property mapping, ephemeral message handling, and manifest command entries. Pair with `../slack/runtime.slash-commands-ts.md` for Slack command patterns, and `../teams/runtime.routing-handlers-ts.md` for Teams `app.message()` patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack slash commands and Teams text commands / message extensions bidirectionally. Cover the three Teams alternatives (text pattern matching with app.message regex, messaging extensions, manifest bot commands), side-by-side code examples for bridging in both directions, payload property mapping (command.text <-> activity.text, trigger_id, response_url <-> send/reply), ack() addition/removal, ephemeral response handling, and manifest configuration. Include a mapping table and common pitfalls for both directions." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-advisor-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-advisor-ts.md new file mode 100644 index 000000000..5330e68f5 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-advisor-ts.md @@ -0,0 +1,738 @@ +# cross-platform-advisor-ts + +## purpose + +Interactive cross-platform bridging advisor. Detects which platform(s) a bot already targets, determines the bridging direction, analyzes the codebase, and walks the developer through every YELLOW/RED bridging decision — with a "take all defaults" escape hatch on every question. + +## rules + +### Phase 0: Direction Detection + +1. **Detect the existing platform.** Scan the codebase in parallel for platform signatures: + + | Pattern to search | Platform detected | + |---|---| + | `@slack/bolt` or `require('slack')` or `app.command(` (Bolt-style) | Slack | + | `@microsoft/teams-ai` or `@microsoft/teams.apps` or `teamsBot` or `BotFrameworkAdapter` | Teams | + | `Block Kit` or `"type":"section"` or `blocks:` (Slack-style) | Slack | + | `AdaptiveCards` or `"type":"AdaptiveCard"` or `CardFactory` | Teams | + | `SLACK_BOT_TOKEN` or `SLACK_APP_TOKEN` or `socketMode` | Slack | + | `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` (Azure Bot) | Teams | + | `ack(` (Slack acknowledgement) | Slack | + | `app.on("message"` or `app.message(` (Teams AI style) | Teams | + +2. **Determine direction.** Based on what was found: + - **Slack only detected** → Direction is **Slack → Teams** (adding Teams support) + - **Teams only detected** → Direction is **Teams → Slack** (adding Slack support) + - **Both detected** → Dual-platform bot already exists. Ask what they want to do (extend, reconcile, or audit). + - **Neither detected** → Ask the developer which platform they're starting from. + +3. **Confirm with the developer.** Present the detected direction: + ``` + header: "Direction" + question: "I detected {platform} patterns in your codebase. Which direction are you bridging?" + options: + - label: "Add Teams to existing Slack bot (Recommended)" + description: "Keep Slack, add Teams as a second platform." + - label: "Add Slack to existing Teams bot" + description: "Keep Teams, add Slack as a second platform." + - label: "Audit existing dual-platform bot" + description: "Both platforms detected — review coverage and gaps." + ``` + + Adapt the recommended option to match what was detected. If Teams was detected, recommend "Add Slack." + +### Phase 1: Codebase Analysis + +4. **Scan for platform API usage.** Search the codebase for these patterns to build a feature inventory. Run all searches in parallel: + + **Slack patterns (relevant when Slack → Teams):** + + | Pattern to search | What it detects | Maps to | + |---|---|---| + | `app.command` | Slash commands | G7 | + | `app.message` | Message pattern matching | G1 | + | `say(` or `respond(` | Simple replies | G2 | + | `blocks:` or `Block Kit` or `"type":"section"` | Block Kit UI | G16 | + | `views.open` or `views.push` | Modals / stacking | G19, Y24 | + | `view_submission` or `viewSubmission` | Modal submission | G20 | + | `app.use(` | Middleware | G14 | + | `ack(` | Slack acknowledgement | G15 | + | `chat.postEphemeral` or `response_type.*ephemeral` | Ephemeral messages | Y1, R1 | + | `reply_broadcast` or `broadcast.*true` | Thread broadcast | Y2 | + | `conversations.replies` | Thread discovery | Y3 | + | `files.upload` or `file_shared` | File upload | Y4/5/6 | + | `link_shared` or `chat.unfurl` | Link unfurling | Y7 | + | `scheduleMessage` or `chat.schedule` | Scheduled messages | Y8, R7 | + | `reminders.add` | Reminders | Y9 | + | `conversations.archive` | Channel archive | Y10, R8 | + | `conversations.kick` or `conversations.invite` | Channel member mgmt | Y11 | + | `app.shortcut` or `global_shortcut` | Global shortcuts | Y13 | + | `message_shortcut` | Message shortcuts | Y14 | + | `block_suggestion` or `app.options` | Dynamic selects | Y15 | + | `app_home_opened` or `views.publish` | App Home | Y16 | + | `view_hash` or `hash` (in modal context) | View hash / race cond | Y17 | + | `blockAction` (inside modals) | Mid-form updates | R4 | + | `ack.*errors` or `response_action.*errors` | Field validation | R5 | + | `notify_on_close` or `view_closed` | Cancel notification | R3 | + | `workflow_step` or `workflow_step_execute` | Workflow Builder | Y12 | + | `reaction_added` or `reaction_removed` | Emoji reactions | R2 | + | `SLACK_APP_TOKEN` or `socketMode` or `SocketModeReceiver` | Socket Mode | Y19 | + | `retryConfig` or `retry` (in Bolt config) | Built-in retry | Y20 | + | `confirm:` or `"confirm"` (on button/action) | Confirmation dialogs | Y21 | + | `*.example.com` in manifest or unfurl config | Unfurl wildcards | Y23 | + | `conversations.create` or `conversations.setTopic` | Channel ops | Y10/Y11 | + + **Teams patterns (relevant when Teams → Slack):** + + | Pattern to search | What it detects | Slack equivalent | + |---|---|---| + | `app.on("message"` or `activity.text` | Message handling | `app.message` | + | `AdaptiveCard` or `CardFactory.adaptiveCard` | Adaptive Cards | Block Kit | + | `app.on("dialog"` or `taskModule` | Task module / dialog | `views.open` modal | + | `proactiveMessage` or `continueConversation` | Proactive messaging | `chat.postMessage` to channel | + | `app.on("messageReaction"` | Reaction events | `reaction_added` | + | `refresh.userIds` | Per-user cards | Ephemeral messages | + | `MessageExtension` or `composeExtension` | Message extensions | Shortcuts | + | `tab.fetch` or `tab.submit` | Personal tabs | App Home | + | `Graph` or `graphClient` | Microsoft Graph calls | Slack Web API | + | `SSO` or `oauth` (Teams context) | SSO / OAuth | Slack OAuth | + | `FileConsentCard` or `supportsFiles` | File consent flow | `files.upload` | + | `messageHandlers` (in manifest) | Link unfurling | `link_shared` | + | `ChannelMessage.Read.Group` (RSC) | All channel messages | Default in Slack | + +5. **Build the feature list.** From scan results, produce a table: `Feature | Found (Y/N) | File:Line | Feature ID`. Only include features where code evidence was found. + +6. **Determine the bot profile.** Use the feature list to classify: + - **Profile A** — Only GREEN features found (G1–G34) + - **Profile B** — GREEN + YELLOW from: Y1, Y2, Y3, Y4/5/6, Y17, Y18, Y21 + - **Profile C** — Profile B + any of: Y7, Y8, Y9, Y10, Y11, Y13, Y14, Y15, Y16, Y23, Y24 + - **Profile D** — Profile C + any of: Y12, Y19, Y20, Y22, or any RED feature is core + + Note: For Teams → Slack direction, the profile classification still applies — the feature IDs map to equivalent complexity tiers in the reverse direction. + +7. **Present the profile.** Show the developer: + - Their detected profile (A/B/C/D) + - The bridging direction (Slack → Teams or Teams → Slack) + - The feature inventory table + - Which phases from the bridging sequence apply (reference `MigrationDecisionMatrix.md` Section 2) + - How many YELLOW and RED decisions they need to make + +### Phase 2: Decision Walkthrough + +8. **Ask one decision at a time.** For each YELLOW/RED feature found in the codebase, present a question using `AskUserQuestion`. Walk through decisions in phase order (matching the bridging phase sequence), not alphabetically. + +9. **Decision ordering.** Present decisions in this order (skip any not found in codebase): + + **Phase 5 — Interactive Responses:** + Y1 (Ephemeral), Y21 (Confirmation dialogs), Y17 (View hash) + + **Phase 7 — Files + Unfurling:** + Y4/5/6 (File upload), Y7 (Link unfurling), Y23 (Unfurl wildcards) + + **Phase 8 — Scheduling + Channel Ops:** + Y8 (Scheduled messages), Y9 (Reminders), Y10 (Channel archive), Y11 (Channel member removal) + + **Phase 9 — Shortcuts + App Home:** + Y13 (Global shortcuts), Y14 (Message shortcuts), Y15 (Dynamic selects), Y16 (App Home), Y24 (Multi-step modals) + + **Phase 10 — Workflows + Distribution:** + Y12 (Workflow Builder), Y22 (App Directory) + + **Phase 11 — Resilience:** + Y18 (All channel messages), Y19 (Socket Mode), Y20 (Retry) + + **Message handling (parallel with Phase 5):** + Y2 (reply_broadcast), Y3 (Thread discovery) + + **RED features (after all YELLOW):** + R1 (True ephemeral), R2 (Emoji reactions), R3 (viewClosed), R4 (Mid-form dynamic), R5 (Field validation), R6 (Dialog stacking), R7 (Scheduled API), R8 (Channel archive), R9 (Retroactive unfurl), R10 (Firewall transport) + + Note: For Teams → Slack direction, adapt the questions to reflect adding Slack equivalents. The same feature IDs apply but the "source" and "target" swap. For example, Y1 becomes "Your bot uses refresh.userIds — Slack supports true ephemeral messages via chat.postEphemeral. Use it directly." + +10. **Every question gets an escape hatch.** The final option in every `AskUserQuestion` call MUST be one of: + - First question: **"You Decide Everything"** — accept all defaults for ALL decisions (YELLOW + RED), skip remaining questions, jump to Phase 3. + - Subsequent questions: **"You Decide Everything Else"** — accept defaults for all REMAINING decisions, skip remaining questions, jump to Phase 3. + + When the developer picks either escape hatch, record all remaining features as "default" and proceed to Phase 3 immediately. + +11. **Question format for YELLOW features.** Each `AskUserQuestion` must include: + - `header`: Feature ID (e.g., "Y1 Ephemeral") + - `question`: Clear question about which approach they prefer (adapted for bridging direction) + - Options from `MigrationDecisionMatrix.md` Section 3, with the **(Recommended)** option listed first + - Final option: the escape hatch + +12. **Question format for RED features.** Each `AskUserQuestion` must include: + - `header`: Feature ID (e.g., "R4 Dynamic") + - `question`: What they want to do about the platform gap (adapted for bridging direction) + - Options matching the strategies from `MigrationDecisionMatrix.md` Section 4 + - Final option: the escape hatch + +13. **Record every decision.** Maintain a running decisions table as you go: + + | Feature | Decision | Option | Notes | + |---|---|---|---| + | Y1 Ephemeral | `refresh.userIds` | A (Recommended) | — | + | Y4/5/6 Files | `sendFile()` helper | B (Recommended) | Default accepted | + | ... | ... | ... | ... | + +### Phase 3: Bridging Plan Output + +14. **Generate the bridging plan.** After all decisions are made (or defaults accepted), produce a single actionable plan with: + + - **Direction** — Which platform exists, which is being added + - **Profile summary** — Profile letter, feature count, phase count + - **Decisions summary** — The completed decisions table + - **Phase-by-phase implementation order** — For each applicable phase: + - Which expert(s) to load: `.experts/bridge/{filename}` + - What to implement + - Which decision applies (if any) + - Go/no-go gate from `MigrationDecisionMatrix.md` Section 2 + - **Helpers to build** — List of helper utilities/plugins chosen (e.g., `sendFile()`, `RetryPlugin`), grouped as a "Phase 0" pre-work step + - **RED feature workarounds** — For each RED feature, the chosen strategy and implementation approach + - **Estimated phase count** — Total phases and which can be parallelized + +15. **Always reference, never duplicate.** Point developers to the specific expert files for implementation details. Do NOT reproduce the code patterns from individual experts — just reference them by filename and rule number. + +### Phase 4: Per-Project Implementation Order + +When implementing each bridged project (whether a single sample or a batch), follow this exact sequence. Do NOT skip steps or reorder them. + +16. **Step 1 — Write all source files.** Write every file the project needs before running any commands: + - `package.json` — dependencies, scripts (`build`, `start`, `dev`) + - `tsconfig.json` — TypeScript compiler config + - `src/index.ts` — main entry point (and any additional `.ts` files) + - `.env.sample` — template with placeholder values for all required env vars + - Stub implementations — where an API is not yet wired up, leave a clearly marked `// TODO:` with an explanation of what should go there so the code still compiles. + +17. **Step 2 — Install dependencies.** Run `npm install` in the project directory. Verify `node_modules` is created and there are no install errors. + +18. **Step 3 — Build and verify.** Run `npm run build`. Must succeed with **zero TypeScript errors**. Fix any issues before proceeding. + +19. **Step 4 — Create app manifest.** + - **Adding Teams:** Create `appPackage/` directory with `manifest.json` (schema v1.19+), `color.png` (192x192), `outline.png` (32x32). The manifest must be valid and ready to zip for sideloading. + - **Adding Slack:** Create or update `manifest.yaml` (Slack app manifest) with bot scopes, event subscriptions, and slash commands. Alternatively, configure via api.slack.com app settings. + +20. **Step 5 — Write README.md.** The README is written **last** because it documents the final state of the project. It must contain: + + - **One-paragraph description** of what the example demonstrates. + - **`## Prerequisites`** — Node.js 18+, platform-specific accounts and registrations. + - **`## Environment Setup`** — step-by-step instructions for filling out `.env`. + - **`## Running Locally`** — full launch sequence with tunneling setup. + - **`## Installing the App`** — platform-specific installation instructions (sideloading for Teams, OAuth install for Slack, or both). + - **`## What Was Bridged`** — bullet list mapping original platform concept → target platform equivalent. + - **`## TODO`** — checklist of remaining items. + +## question templates + +Use these as the basis for each `AskUserQuestion` call. Adapt the question text based on what was found in the codebase (e.g., mention the specific file where the feature was detected) and the bridging direction. + +### Y1 — Ephemeral Messages +``` +question: "Your bot uses ephemeral messages ({file}:{line}). How should the target platform handle user-only visibility?" +header: "Y1 Ephemeral" +options: + - label: "refresh.userIds (Recommended)" + description: "Wrap cards with refresh.userIds for per-user content. Covers ~80% of cases. 4-8 hrs." + - label: "Send to 1:1 chat" + description: "Route ephemeral content to user's personal bot chat. Different UX but reliable. 2-4 hrs." + - label: "Build sendEphemeral() helper" + description: "SDK wrapper auto-detecting context. Best if reused across multiple bots. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, ephemeral is natively supported via `chat.postEphemeral`. This question may be skipped — just use the native API. + +### Y2 — Threaded Replies with reply_broadcast +``` +question: "Your bot uses reply_broadcast ({file}:{line}). How should the target platform handle thread + channel posting?" +header: "Y2 Broadcast" +options: + - label: "Two API calls (Recommended)" + description: "Call reply() and send() separately. Two lines of code, 1-2 hrs." + - label: "Build reply(text, { broadcast }) wrapper" + description: "Convenience method that internally sends both calls. 2-4 hrs." + - label: "{escape hatch}" +``` + +### Y3 — Thread Discovery +``` +question: "Your bot reads thread replies ({file}:{line}). How should the target platform fetch thread history?" +header: "Y3 Threads" +options: + - label: "Graph API direct (Recommended)" + description: "GET /messages/{id}/replies with ChannelMessage.Read.All permission. 4-8 hrs." + - label: "Build getThreadReplies() helper" + description: "Wrapper encapsulating Graph client setup and auth. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.replies` directly — native API. + +### Y4/5/6 — File Upload +``` +question: "Your bot uploads files ({file}:{line}). How should the target platform handle file operations?" +header: "Y4-6 Files" +options: + - label: "Build sendFile() helper (Recommended)" + description: "Unified wrapper: auto-detects personal/channel, routes to OneDrive/SharePoint, chunks >4MB. 24-40 hrs. The manual flow is a 30-line footgun." + - label: "Manual FileConsentCard flow" + description: "Implement the 3-step consent flow yourself. 16-24 hrs per upload pattern." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `files.uploadV2` directly — much simpler than the Teams consent flow. + +### Y7 — Link Unfurling +``` +question: "Your bot unfurls links ({file}:{line}). How should the target platform handle link previews?" +header: "Y7 Unfurl" +options: + - label: "Cache-first with prefetch (Recommended)" + description: "Cache middleware wraps handler. Without this, the 5-second deadline silently kills slow unfurls. 12-16 hrs." + - label: "Synchronous handler only" + description: "Direct handler, must return within 5 seconds. Only viable for fast data sources. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y8 — Scheduled Messages +``` +question: "Your bot schedules messages ({file}:{line}). How should the target platform handle deferred delivery?" +header: "Y8 Schedule" +options: + - label: "Functions timer + Cosmos DB (Recommended)" + description: "Store in DB, Azure Functions timer polls and sends via proactive messaging. 16-24 hrs." + - label: "Full scheduler plugin" + description: "Reusable package with scheduleMessage()/cancelScheduledMessage(). 32-48 hrs." + - label: "Power Automate delegation" + description: "Offload to Power Automate flows. Requires license. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `chat.scheduleMessage` directly — native API. + +### Y9 — Reminders +``` +question: "Your bot sets reminders ({file}:{line}). How should the target platform handle reminder delivery?" +header: "Y9 Reminders" +options: + - label: "Piggyback on scheduler (Recommended)" + description: "Reuse Y8 scheduler with setReminder() sending to 1:1 chat. 4-8 hrs if scheduler exists." + - label: "Power Automate + Planner" + description: "Create Planner tasks with due date notifications. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `reminders.add` directly — native API. + +### Y10 — Channel Archive +``` +question: "Your bot archives channels ({file}:{line}). How should the target platform simulate channel archival?" +header: "Y10 Archive" +options: + - label: "Rename + description (Recommended)" + description: "Prefix with [ARCHIVED], update description. Cosmetic but non-destructive. 4-8 hrs." + - label: "Rename + remove members" + description: "Stronger enforcement but destructive — members must be re-invited to undo. 8-12 hrs." + - label: "Team-level archive" + description: "Archive entire Team. Only works if channel is in a dedicated Team. 2-4 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.archive` directly — native API. + +### Y11 — Channel Member Removal +``` +question: "Your bot removes channel members ({file}:{line}). How should the target platform handle member removal?" +header: "Y11 Members" +options: + - label: "Two-step Graph API (Recommended)" + description: "List members to resolve membership-id, then delete. Simple and direct. 4-6 hrs." + - label: "Build removeChannelMember() helper" + description: "Wrapper that resolves membership ID internally. Cleaner API. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.kick` directly — native API. + +### Y12 — Workflow Builder +``` +question: "Your bot uses Workflow Builder ({file}:{line}). How should the target platform handle workflow automation?" +header: "Y12 Workflows" +options: + - label: "Bot-driven orchestration (Recommended)" + description: "Keep logic in the bot. No license dependency, full control. 16-40 hrs." + - label: "Power Automate rebuild" + description: "Rebuild in Power Automate. Custom steps need Premium license. 24-80 hrs." + - label: "Hybrid approach" + description: "Simple flows → Power Automate, complex → bot-driven. Two systems. Varies." + - label: "{escape hatch}" +``` + +### Y13 — Global Shortcuts +``` +question: "Your bot uses global shortcuts ({file}:{line}). How should the target platform expose quick actions?" +header: "Y13 Shortcuts" +options: + - label: "Compose extension (Recommended)" + description: "composeExtensions with commandBox context. Always opens task module. 8-12 hrs." + - label: "Minimal-dismiss pattern" + description: "Task module returns tiny 'Done' card for fire-and-forget actions. 4-8 hrs." + - label: "Bot command replacement" + description: "Replace shortcut with typed command. Simpler but less discoverable. 2-4 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map compose extensions to `app.shortcut` with a global shortcut callback. + +### Y14 — Message Shortcuts +``` +question: "Your bot uses message shortcuts ({file}:{line}). How should the target platform expose message actions?" +header: "Y14 MsgAction" +options: + - label: "Action-based message extension (Recommended)" + description: "composeExtensions with message context. Direct mapping. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map action-based message extensions to `app.shortcut` with `message_shortcut` type. + +### Y15 — Dynamic Selects +``` +question: "Your bot uses dynamic select menus ({file}:{line}). How should the target platform handle server-filtered dropdowns?" +header: "Y15 Selects" +options: + - label: "Pre-populated ChoiceSet (Recommended)" + description: "Load all options at dialog open, client-side filtering. Works up to ~500 items. 2-4 hrs." + - label: "Two-step dialog" + description: "Step 1: text search. Step 2: filtered results as ChoiceSet. Works for any size. 8-12 hrs." + - label: "Custom searchable task module" + description: "Embed a web view with search-as-you-type UI. Full control. 16-24 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `block_suggestion` with `external_data_source` for native dynamic selects. + +### Y16 — App Home +``` +question: "Your bot uses App Home ({file}:{line}). How should the target platform present the bot's home experience?" +header: "Y16 AppHome" +options: + - label: "tab.fetch handler (Recommended)" + description: "Personal tab fires on every open. Closest to AppHomeOpenedEvent. 4-8 hrs." + - label: "install.add welcome only" + description: "Send welcome message once on install. Simple but fires only once. 1-2 hrs." + - label: "Static tab (web content)" + description: "Full web page embedded as personal tab. Richer but needs hosting. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish`. + +### Y17 — View Hash +``` +question: "Your bot uses view_hash for race conditions ({file}:{line}). How should the target platform protect against stale updates?" +header: "Y17 ViewHash" +options: + - label: "Manual _version field (Recommended)" + description: "Inject version counter into Action.Submit.data, reject stale. 2-4 hrs." + - label: "Card versioning middleware" + description: "SDK plugin auto-injecting and checking versions. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use the native `view_hash` parameter in `views.update` — built-in. + +### Y18 — All Channel Messages +``` +question: "Your bot receives all channel messages without @mention ({file}:{line}). How should the target platform enable this?" +header: "Y18 RSC" +options: + - label: "RSC permission (Recommended)" + description: "Add ChannelMessage.Read.Group to manifest. Config-only, no code change. 1-2 hrs." + - label: "Require @mention" + description: "Change UX to require @mention. Simplifies permissions. 0 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack receives all channel messages by default when the bot is in the channel. No special config needed. + +### Y19 — Socket Mode +``` +question: "Your bot uses Socket Mode ({file}:{line}). The target platform requires inbound HTTPS. How do you want to handle transport?" +header: "Y19 Transport" +options: + - label: "Deploy to Azure (Recommended)" + description: "Host in Azure for production. Use Dev Tunnels for local dev. 4-8 hrs." + - label: "Azure Relay" + description: "Hybrid connection for strict on-premises firewalls. Adds latency. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack supports Socket Mode for firewall-friendly deployments — a simpler story. + +### Y20 — Built-in Retry +``` +question: "Your bot uses Bolt's retryConfig ({file}:{line}). How should the target platform handle retry and resilience?" +header: "Y20 Retry" +options: + - label: "Build RetryPlugin (Recommended)" + description: "Drop-in plugin with exponential backoff, jitter, circuit breaker. Bad retry causes cascading failures. 12-16 hrs." + - label: "Manual retry wrapper" + description: "Hand-roll backoff around outbound calls. Simpler but easy to get wrong. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y21 — Confirmation Dialogs +``` +question: "Your bot uses confirmation dialogs on buttons ({file}:{line}). How should the target platform confirm destructive actions?" +header: "Y21 Confirm" +options: + - label: "Action.ShowCard inline (Recommended)" + description: "Inline expand with Yes/No buttons. Native Adaptive Card pattern. 2-4 hrs." + - label: "Task module confirm" + description: "Small dialog for confirmation. More prominent. 4-6 hrs." + - label: "Build confirmAction() helper" + description: "Template function generating confirm cards. Reusable. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use the native `confirm` object on button elements — built-in. + +### Y22 — App Directory +``` +question: "Your bot is listed in an app directory. How should it be distributed on the target platform?" +header: "Y22 Distrib" +options: + - label: "Org app catalog (Recommended)" + description: "Publish to organization catalog. Requires Teams admin approval. 2-4 hrs." + - label: "Admin sideload" + description: "Upload directly via Teams Admin Center. Quick but no catalog listing. 1-2 hrs." + - label: "Partner Center (public)" + description: "Submit to Teams App Store. 1-2 week review. Requires Partner Network account. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, submit to the Slack App Directory via api.slack.com. + +### Y23 — Unfurl Domain Wildcards +``` +question: "Your bot uses wildcard domain matching for link unfurling ({file}:{line}). How should the target platform list domains?" +header: "Y23 Wildcards" +options: + - label: "Manual enumeration (Recommended)" + description: "List every subdomain in manifest. Fine for <10 subdomains. 1-2 hrs." + - label: "Manifest generator script" + description: "Script reads subdomains from config and generates manifest array. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y24 — Multi-Step Modal Stacking +``` +question: "Your bot uses views.push for modal stacking ({file}:{line}). How should the target platform handle multi-step forms?" +header: "Y24 Stacking" +options: + - label: "Flatten into single dialog (Recommended)" + description: "Single dialog with step routing in submit handler. Manageable for 2-3 steps. 8-16 hrs." + - label: "Build StepDialog helper" + description: "Reusable class managing step state, back/forward. Worth it if 3+ wizard flows. 16-24 hrs." + - label: "Separate sequential dialogs" + description: "Close current, open next. No back navigation. Degraded UX. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `views.push` for stacking — up to 3 levels supported. + +### R1 — True Ephemeral Messages +``` +question: "Your bot relies on true ephemeral messages — a Teams platform gap. Teams has no visibility:'user' flag. How do you want to handle this?" +header: "R1 Ephemeral" +options: + - label: "Accept & Redesign (Recommended)" + description: "refresh.userIds for cards, 1:1 chat for text. Different but functional." + - label: "Defer" + description: "Drop ephemeral behavior entirely. Show messages to everyone." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, this is a non-issue — Slack has native ephemeral support. + +### R2 — Custom Emoji Reactions +``` +question: "Your bot uses emoji reactions as workflow signals — Teams only has 6 fixed reactions. How do you want to handle this?" +header: "R2 Reactions" +options: + - label: "Accept & Redesign (Recommended)" + description: "Replace reaction workflows with Action.Submit card buttons. Better for audit trails." + - label: "Map to 6 fixed reactions" + description: "Map your most important reactions to like/heart/laugh/surprised/sad/angry. Lossy." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack supports unlimited custom emoji reactions — direct mapping. + +### R3 — viewClosed / Cancel Notification +``` +question: "Your bot uses viewClosed callbacks — Teams sends no notification on dialog dismiss. How do you want to handle this?" +header: "R3 Cancel" +options: + - label: "Build Custom (Recommended)" + description: "Timeout-based cleanup (5-min TTL) + explicit Cancel button inside the dialog." + - label: "Defer" + description: "Drop cancel cleanup entirely. Accept potential stale locks." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `notify_on_close: true` in `views.open` — native support. + +### R4 — Mid-Form Dynamic Updates +``` +question: "Your bot uses blockAction inside modals for dynamic form updates — a Teams platform gap. How do you want to handle this?" +header: "R4 Dynamic" +options: + - label: "Accept & Redesign (Recommended)" + description: "Multi-step dialogs for dependent fields. Action.ToggleVisibility for simple show/hide." + - label: "Build custom web-based task module" + description: "Embed a full web form in the task module for complete control. Much more effort." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `block_actions` inside modals with `views.update` — native support. + +### R5 — Server-Side Field Validation +``` +question: "Your bot uses ackWithErrors for inline field validation — a Teams platform gap. How do you want to handle this?" +header: "R5 Validate" +options: + - label: "Build Custom (Recommended)" + description: "Re-open dialog with pre-populated data + error messages in field labels." + - label: "Client-side only" + description: "Use isRequired/regex/maxLength. Covers simple cases only." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `response_action: errors` in `view_submission` handler — native support. + +### R6 — Dialog Stacking +``` +question: "Your bot uses views.push for dialog stacking — a Teams platform gap. How do you want to handle this?" +header: "R6 Stacking" +options: + - label: "Accept & Redesign (Recommended)" + description: "Single dialog with step routing. Same approach as Y24. Simulate Back with a button." + - label: "Build custom web-based task module" + description: "Embed a web app with real navigation in the task module. Full control. High effort." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `views.push` — up to 3 levels. + +### R7 — Scheduled Message API +``` +question: "Your bot depends on chat.scheduleMessage — a Teams platform gap. Teams has no server-side scheduling. How do you want to handle this?" +header: "R7 ScheduleAPI" +options: + - label: "Build Custom (Recommended)" + description: "Self-managed scheduler from Y8 (Cosmos DB + Functions timer). Works, just boilerplate." + - label: "Defer" + description: "Drop scheduling entirely. Users trigger messages manually." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `chat.scheduleMessage` — direct mapping. + +### R8 — Channel Archive +``` +question: "Your bot archives individual channels — Teams can only archive entire Teams. How do you want to handle this?" +header: "R8 Archive" +options: + - label: "Accept & Redesign (Recommended)" + description: "Rename with [ARCHIVED] prefix. Good enough for 90% of cases." + - label: "Rename + remove all members" + description: "Stronger enforcement but destructive. Hard to undo." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `conversations.archive` — direct mapping. + +### R9 — Retroactive Link Unfurling +``` +question: "Your bot benefits from retroactive link unfurling — Teams only unfurls links in new messages. How do you want to handle this?" +header: "R9 Retroactive" +options: + - label: "Defer (Recommended)" + description: "No workaround exists. Don't waste time. New messages unfurl fine." + - label: "Build manual preview command" + description: "Bot command where users paste a URL to get a preview card. Niche." + - label: "{escape hatch}" +``` + +### R10 — Firewall-Friendly Transport +``` +question: "Your bot relies on Socket Mode for firewall-friendly transport — Teams requires inbound HTTPS. How do you want to handle this?" +header: "R10 Firewall" +options: + - label: "Accept & Redesign (Recommended)" + description: "Deploy to Azure (it's 2026). Dev Tunnels for local dev." + - label: "Azure Relay" + description: "Hybrid connection for strict on-premises requirements. Adds latency." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack's Socket Mode provides firewall-friendly transport natively. + +## defaults table + +When the developer picks "You Decide Everything" or "You Decide Everything Else", apply these defaults for all remaining decisions: + +| Feature | Default Option | Strategy | +|---|---|---| +| Y1 | A | `refresh.userIds` (Slack→Teams) / `chat.postEphemeral` (Teams→Slack) | +| Y2 | A | Two API calls (Slack→Teams) / `reply_broadcast` (Teams→Slack) | +| Y3 | A | Graph API direct (Slack→Teams) / `conversations.replies` (Teams→Slack) | +| Y4/5/6 | B | `sendFile()` helper (Slack→Teams) / `files.uploadV2` (Teams→Slack) | +| Y7 | B | Cache-first with prefetch (Slack→Teams) / `link_shared` + `chat.unfurl` (Teams→Slack) | +| Y8 | A | Functions timer + Cosmos DB (Slack→Teams) / `chat.scheduleMessage` (Teams→Slack) | +| Y9 | A | Piggyback on Y8 scheduler (Slack→Teams) / `reminders.add` (Teams→Slack) | +| Y10 | A | Rename + description (Slack→Teams) / `conversations.archive` (Teams→Slack) | +| Y11 | A | Two-step Graph API (Slack→Teams) / `conversations.kick` (Teams→Slack) | +| Y12 | B | Bot-driven orchestration | +| Y13 | A | Compose extension (Slack→Teams) / `app.shortcut` (Teams→Slack) | +| Y14 | A | Action-based message extension (Slack→Teams) / `message_shortcut` (Teams→Slack) | +| Y15 | A | Pre-populated ChoiceSet (Slack→Teams) / `block_suggestion` (Teams→Slack) | +| Y16 | B | `tab.fetch` handler (Slack→Teams) / `views.publish` (Teams→Slack) | +| Y17 | A | Manual `_version` field (Slack→Teams) / `view_hash` (Teams→Slack) | +| Y18 | A | RSC permission (Slack→Teams) / Default in Slack (Teams→Slack) | +| Y19 | B | Deploy to Azure (Slack→Teams) / Socket Mode (Teams→Slack) | +| Y20 | B | `RetryPlugin` (Slack→Teams) / Bolt `retryConfig` (Teams→Slack) | +| Y21 | A | `Action.ShowCard` inline (Slack→Teams) / `confirm` object (Teams→Slack) | +| Y22 | B | Org app catalog (Slack→Teams) / Slack App Directory (Teams→Slack) | +| Y23 | A | Manual enumeration (Slack→Teams) / Wildcard support (Teams→Slack) | +| Y24 | A | Flatten into single dialog (Slack→Teams) / `views.push` (Teams→Slack) | +| R1 | — | Accept & Redesign (Slack→Teams) / Native (Teams→Slack) | +| R2 | — | Accept & Redesign (Slack→Teams) / Native (Teams→Slack) | +| R3 | — | Build Custom (Slack→Teams) / `notify_on_close` (Teams→Slack) | +| R4 | — | Accept & Redesign (Slack→Teams) / `block_actions` + `views.update` (Teams→Slack) | +| R5 | — | Build Custom (Slack→Teams) / `response_action: errors` (Teams→Slack) | +| R6 | — | Accept & Redesign (Slack→Teams) / `views.push` (Teams→Slack) | +| R7 | — | Build Custom (Slack→Teams) / `chat.scheduleMessage` (Teams→Slack) | +| R8 | — | Accept & Redesign (Slack→Teams) / `conversations.archive` (Teams→Slack) | +| R9 | — | Defer | +| R10 | — | Accept & Redesign (Slack→Teams) / Socket Mode (Teams→Slack) | + +## instructions + +Pair with: +- `MigrationDecisionMatrix.md` — source of truth for all decision options, effort estimates, and profile definitions +- All 22 bridge experts in `.experts/bridge/` — referenced in the Phase 3 output for implementation details +- `SlackToTeamsMigrationAnalysis.md` — cross-reference for feature status (G/Y/R) + +Do a web search for: +- "Microsoft Teams Bot Framework SDK TypeScript latest changes 2026" +- "Slack Bolt SDK TypeScript latest changes 2026" + +## research + +Deep Research prompt: + +"Write an interactive cross-platform bridging advisor for Slack↔Teams bot development. Cover codebase analysis (detecting both Slack and Teams API patterns), direction detection (which platform exists, which to add), bot profile classification (A-D by complexity), and per-feature decision walkthrough for 24 YELLOW and 10 RED platform gaps — with bidirectional defaults for each direction. Include question templates with effort estimates and a defaults table for one-click acceptance." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-architecture-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-architecture-ts.md new file mode 100644 index 000000000..52735760b --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/cross-platform-architecture-ts.md @@ -0,0 +1,165 @@ +# cross-platform-architecture-ts + +## purpose + +Architecture patterns for hosting both a Slack bot (Bolt.js) and a Teams bot (Bot Framework / Teams SDK) in a single TypeScript server — shared Express instance, separate receiver pipelines, shared business logic layer, and deployment considerations. + +## rules + +1. **Use a single Express server as the HTTP foundation.** Both Slack (HTTP receiver) and Teams (webhook POST) can share one Express app on one port. Mount Slack routes at `/slack/events` and Teams routes at `/api/messages`. +2. **Keep bot SDKs in separate modules.** Initialize Bolt's `ExpressReceiver` and Teams' `CloudAdapter` independently. Neither should know about the other. Share only the business logic layer. +3. **Extract business logic into a platform-agnostic service layer.** Functions like `processUserMessage(text, userId, context)` should return platform-neutral results (text, structured data). Platform adapters convert to Block Kit or Adaptive Cards. +4. **Use Bolt's `ExpressReceiver` (not the default `HTTPReceiver`) for shared Express.** Create the Express app yourself, pass it to `ExpressReceiver` via the `app` option, and also mount Teams routes on the same instance. +5. **For Socket Mode Slack + HTTP Teams, run both receivers.** Start `SocketModeReceiver` for Slack (WebSocket, no HTTP needed) and Express for Teams webhook. This is simpler than sharing Express — Slack doesn't need an HTTP endpoint at all. +6. **Normalize user identity across platforms.** Map Slack user IDs (`U...`) and Teams AAD object IDs to a common identity. Store mappings in a shared database keyed by email or external ID. +7. **Normalize conversation context.** Create a `ConversationContext` type with `platform: "slack" | "teams"`, `channelId`, `threadId`, `userId`, and `replyFn`. Each platform adapter populates this from its native event. +8. **Handle media differences in the adapter layer.** Slack uses Block Kit (`mrkdwn`, `blocks[]`). Teams uses Adaptive Cards (JSON schema, `AdaptiveCard`). The service layer should return structured data that each adapter renders into the platform's format. +9. **Share environment config but separate credentials.** Use a single `.env` or config file with prefixed keys: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_TENANT_ID`. +10. **Deploy as a single container or serverless function.** Both bots run in the same Node.js process. Use health checks for both: Slack via Socket Mode ping/pong, Teams via a health probe endpoint. + +## patterns + +### Shared Express with ExpressReceiver (Slack HTTP) + Teams webhook + +```typescript +import express from "express"; +import { App, ExpressReceiver } from "@slack/bolt"; +import { CloudAdapter, ConfigurationServiceClientCredentialFactory, createBotFrameworkAuthenticationFromConfiguration } from "botbuilder"; + +// 1. Create shared Express app +const expressApp = express(); + +// 2. Initialize Slack with ExpressReceiver +const slackReceiver = new ExpressReceiver({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, + app: expressApp, // share the Express instance + endpoints: "/slack/events", // Slack events endpoint +}); + +const slackApp = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver: slackReceiver, +}); + +// 3. Initialize Teams on the same Express app +const credFactory = new ConfigurationServiceClientCredentialFactory({ + MicrosoftAppId: process.env.TEAMS_APP_ID!, + MicrosoftAppPassword: process.env.TEAMS_APP_PASSWORD!, + MicrosoftAppTenantId: process.env.TEAMS_TENANT_ID!, +}); +const auth = createBotFrameworkAuthenticationFromConfiguration(null, credFactory); +const adapter = new CloudAdapter(auth); + +expressApp.post("/api/messages", async (req, res) => { + await adapter.process(req, res, (context) => teamsBot.run(context)); +}); + +// 4. Health check +expressApp.get("/health", (_req, res) => res.json({ slack: "ok", teams: "ok" })); + +// 5. Start +expressApp.listen(3000, () => console.log("Dual bot running on :3000")); +``` + +### Socket Mode Slack + HTTP Teams (simpler) + +```typescript +import { App } from "@slack/bolt"; +import express from "express"; + +// Slack: Socket Mode (no HTTP needed) +const slackApp = new App({ + token: process.env.SLACK_BOT_TOKEN!, + appToken: process.env.SLACK_APP_TOKEN!, + socketMode: true, +}); + +// Teams: Express webhook +const expressApp = express(); +// ... mount Teams adapter on expressApp ... + +await slackApp.start(); // WebSocket +expressApp.listen(3978, () => {}); // HTTP for Teams +``` + +### Platform-agnostic service layer + +```typescript +// service/message-handler.ts — no platform imports +export interface BotResponse { + text: string; + structured?: { + title: string; + body: string; + actions?: { label: string; id: string }[]; + }; +} + +export async function handleUserMessage( + text: string, + userId: string, + platform: "slack" | "teams" +): Promise { + // Business logic, AI calls, database queries — platform-agnostic + return { + text: `You said: ${text}`, + structured: { title: "Echo", body: text }, + }; +} + +// adapters/slack-adapter.ts +import { handleUserMessage } from "../service/message-handler.js"; + +slackApp.message(/.*/, async ({ message, say }) => { + const response = await handleUserMessage( + (message as any).text ?? "", + (message as any).user ?? "", + "slack" + ); + await say(response.text); // or convert response.structured to Block Kit +}); + +// adapters/teams-adapter.ts +import { handleUserMessage } from "../service/message-handler.js"; + +class TeamsBot extends ActivityHandler { + constructor() { + super(); + this.onMessage(async (context, next) => { + const response = await handleUserMessage( + context.activity.text ?? "", + context.activity.from?.id ?? "", + "teams" + ); + await context.sendActivity(response.text); // or convert to Adaptive Card + await next(); + }); + } +} +``` + +## pitfalls + +- **Express body parsing conflicts.** Slack needs raw body parsing for signature verification. Teams needs `express.json()`. Order middleware carefully — apply `express.json()` only to Teams routes, and let `ExpressReceiver` handle Slack routes' body parsing. +- **Port conflicts in development.** If Slack's `ExpressReceiver` and your Teams server both try to listen on the same port, one will fail. Share a single `listen()` call, or use Socket Mode for Slack. +- **Credential leakage between adapters.** Keep Slack and Teams clients in separate modules. A bug that passes the Slack token to a Teams API call (or vice versa) is hard to debug and a security risk. +- **Adaptive Cards and Block Kit are not interchangeable.** Don't try to build a "universal card format" — the data models are fundamentally different. Keep a thin adapter that transforms structured data to each format. +- **Tunneling for local development.** You need two tunnel endpoints (one for Slack, one for Teams) or route both through the same tunnel with path-based routing. ngrok or Cloudflare Tunnel work for both. + +## references + +- Bolt.js `ExpressReceiver`: https://slack.dev/bolt-js/concepts/custom-routes +- Bot Framework `CloudAdapter`: https://learn.microsoft.com/en-us/javascript/api/botbuilder/cloudadapter +- Express 5: https://expressjs.com/en/5x/api.html + +## instructions + +Use this expert when designing a server that hosts both Slack and Teams bots, or when deciding on a deployment architecture for multi-platform bot support. This is the foundational architecture expert for the slack-plus-teams project's core use case. + +Pair with: `runtime.bolt-foundations-ts.md` (Slack setup), `../teams/runtime.app-init-ts.md` (Teams setup), `identity-oauth-bridge-ts.md` (cross-platform identity), `ui-block-kit-adaptive-cards-ts.md` (UI adapter patterns). + +## research + +Deep Research prompt: + +"Document architecture patterns for hosting both a Slack Bolt.js bot and a Microsoft Teams Bot Framework bot in a single Node.js/TypeScript server. Cover: shared Express server with route separation, ExpressReceiver for Slack HTTP mode, Socket Mode for Slack + separate HTTP for Teams, CloudAdapter integration on shared Express, platform-agnostic service layer design, Block Kit vs Adaptive Card adapter pattern, identity normalization across platforms, credential separation, environment configuration, health monitoring for both platforms, deployment as single container, and body parsing middleware ordering for signature verification compatibility." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/events-activities-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/events-activities-ts.md new file mode 100644 index 000000000..ddd3ea71e --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/events-activities-ts.md @@ -0,0 +1,413 @@ +# events-activities-ts + +## purpose + +Bridges Slack event subscriptions and Teams activity handlers for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack's `app.event('event_name')` maps to Teams' `app.on('route_name')` pattern. The event names and payload shapes are completely different between the two platforms. Always consult the mapping table below for the correct Teams route. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +2. Slack's `app.message(pattern)` maps directly to Teams' `app.message(pattern)` for pattern-matched messages. For a catch-all, Slack uses `app.message(async ...)` while Teams uses `app.on('message', async ...)`. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. Slack's `app.event('app_mention')` has no dedicated Teams route. In Teams channels, bots receive messages only when @mentioned, so the standard `app.on('message')` handler already implies a mention context. Check `activity.entities` for mention details or use the `mention` route if available. [learn.microsoft.com -- Mentions in bots](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations#receive-only-at-mentioned-messages) +4. Slack's `app.event('member_joined_channel')` and `app.event('member_left_channel')` map to Teams' `app.on('conversationUpdate')` with inspection of `activity.membersAdded` or `activity.membersRemoved` arrays. [learn.microsoft.com -- conversationUpdate](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events) +5. Slack's `say()` maps to Teams' `send()` for posting a new message. Slack's threaded replies via `say({ thread_ts })` map to Teams' `reply()` method which uses `replyToId` internally for threaded conversation. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +6. Slack's `app.event('reaction_added')` and `app.event('reaction_removed')` map to Teams' `app.on('messageReaction')` route. Teams delivers both added and removed reactions in a single route -- inspect `activity.reactionsAdded` and `activity.reactionsRemoved` arrays. [learn.microsoft.com -- Message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events#message-reaction-events) +7. Slack's ephemeral messages (`respond({ response_type: 'ephemeral' })`) have **no Teams equivalent**. Redesign ephemeral responses as: (a) messages in personal (1:1) chat, (b) Adaptive Cards with user-specific `Action.Execute` refresh, or (c) simply visible messages if privacy is not critical. [learn.microsoft.com -- Conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics) +8. In Teams channels, bots require @mention to receive messages (default behavior). This is fundamentally different from Slack where bots receive all channel messages. To receive all messages without mention, the app must request Resource-Specific Consent (RSC) permission `ChannelMessage.Read.Group`. [learn.microsoft.com -- RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent) +9. Slack's `app.event('app_home_opened')` (App Home tab) maps to Teams' static tab or `tab.open` invoke route for personal tabs. There is no direct equivalent -- Teams tabs are web pages rendered in an iframe, not bot-driven views. [learn.microsoft.com -- Personal tabs](https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs) +10. Teams provides install/uninstall events via `app.on('install.add')` and `app.on('install.remove')` which have no direct Slack equivalent. Use these to send welcome messages and store conversation references for proactive messaging. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +11. **Teams has only 6 reaction types** (`like`, `heart`, `laugh`, `surprised`, `sad`, `angry`) — Slack supports unlimited custom emoji reactions. Bots that use reactions as workflow triggers (e.g., `:white_check_mark:` to mark approved, `:eyes:` to claim a ticket) must be redesigned. Replace reaction-based workflows with `Action.Submit` buttons on Adaptive Cards, which provide explicit, typed actions instead of ambiguous emoji semantics. [learn.microsoft.com -- Message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events#message-reaction-events) +12. **Threading model differs significantly.** Slack uses `thread_ts` to identify a parent message and `reply_broadcast` to also post a thread reply to the channel. Teams uses `replyToId` in the activity and the `reply()` method. There is **no "also send to channel"** equivalent in Teams — a reply stays in the thread. Thread discovery requires the Graph API: `GET /teams/{team-id}/channels/{channel-id}/messages/{message-id}/replies`. This Graph call requires `ChannelMessage.Read.All` application permission. [learn.microsoft.com -- List replies](https://learn.microsoft.com/en-us/graph/api/chatmessage-list-replies) + +## patterns + +### Migrating message handlers (say to send, thread_ts to reply) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Pattern-matched message +app.message(/^hello$/i, async ({ message, say }) => { + await say(`Hello <@${(message as any).user}>!`); +}); + +// Catch-all message handler +app.message(async ({ message, say }) => { + if (message.subtype) return; + // Reply in thread + await say({ + text: `You said: ${(message as any).text}`, + thread_ts: (message as any).ts, + }); +}); + +// App mention event +app.event("app_mention", async ({ event, say }) => { + await say(`Thanks for mentioning me, <@${event.user}>!`); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// Pattern-matched message (same API shape as Slack) +app.message(/^hello$/i, async ({ send, activity }) => { + await send(`Hello ${activity.from.name}!`); +}); + +// Catch-all message handler +app.on("message", async ({ activity, reply }) => { + // reply() creates a threaded reply (like say({ thread_ts }) in Slack) + await reply(`You said: "${activity.text}"`); +}); + +// No separate app_mention route needed -- in channels, bots only +// receive messages when @mentioned, so app.on('message') covers it. +// For explicit mention detection: +app.on("message", async ({ activity, send }) => { + const mentions = activity.entities?.filter( + (e: any) => e.type === "mention" && e.mentioned?.id !== activity.recipient?.id + ); + if (mentions?.length) { + await send("I see you mentioned someone!"); + } +}); + +app.start(3978); +``` + +### Migrating member join/leave and reaction events + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Member joined channel +app.event("member_joined_channel", async ({ event, say }) => { + await say(`Welcome to the channel, <@${event.user}>!`); +}); + +// Member left channel +app.event("member_left_channel", async ({ event, client }) => { + await client.chat.postMessage({ + channel: event.channel, + text: `<@${event.user}> has left the channel.`, + }); +}); + +// Reaction added +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "eyes") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Someone is looking at this! :eyes:`, + thread_ts: event.item.ts, + }); + } +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// Member joined -- conversationUpdate with membersAdded +app.on("conversationUpdate", async ({ activity, send }) => { + if (activity.membersAdded?.length) { + for (const member of activity.membersAdded) { + // Skip the bot itself + if (member.id !== activity.recipient?.id) { + await send(`Welcome to the channel, ${member.name}!`); + } + } + } + + // Member left -- conversationUpdate with membersRemoved + if (activity.membersRemoved?.length) { + for (const member of activity.membersRemoved) { + if (member.id !== activity.recipient?.id) { + await send(`${member.name} has left the channel.`); + } + } + } +}); + +// Reaction events -- messageReaction route +app.on("messageReaction" as any, async ({ activity, send }) => { + if (activity.reactionsAdded?.length) { + for (const reaction of activity.reactionsAdded) { + if (reaction.type === "like") { + await send("Someone liked a message!"); + } + } + } +}); + +// Install event (no Slack equivalent) -- good for welcome messages +app.on("install.add", async ({ send, activity }) => { + await send("Thanks for installing me! Type 'help' to get started."); +}); + +app.start(3978); +``` + +### Event mapping reference table + +| Slack Event / Handler | Teams Route / Handler | Notes | +|---|---|---| +| `app.message(pattern)` | `app.message(pattern)` | Direct equivalent; Teams uses RegExp | +| `app.message(async ...)` (catch-all) | `app.on('message', async ...)` | Named route for catch-all | +| `app.event('app_mention')` | `app.on('message')` | Channel messages imply @mention | +| `app.event('member_joined_channel')` | `app.on('conversationUpdate')` + `membersAdded` | Check `activity.membersAdded` array | +| `app.event('member_left_channel')` | `app.on('conversationUpdate')` + `membersRemoved` | Check `activity.membersRemoved` array | +| `app.event('reaction_added')` | `app.on('messageReaction')` + `reactionsAdded` | Inspect `activity.reactionsAdded` | +| `app.event('reaction_removed')` | `app.on('messageReaction')` + `reactionsRemoved` | Inspect `activity.reactionsRemoved` | +| `app.event('message_changed')` | `app.on('messageUpdate')` | Message edit event | +| `app.event('message_deleted')` | `app.on('messageDelete')` | Message deletion event | +| `app.event('app_home_opened')` | `app.on('tab.open')` or static tab | Web-based tab, not bot view | +| `app.event('team_join')` | `app.on('conversationUpdate')` + `membersAdded` | Same route as channel join | +| `say(text)` | `send(text)` | Post new message | +| `say({ thread_ts })` | `reply(text)` | Threaded reply | +| `say({ thread_ts, reply_broadcast: true })` | `reply(text)` + `send(text)` | No single-call equivalent; must send twice | +| `respond({ response_type: 'ephemeral' })` | *(no equivalent)* | Redesign required | +| Reaction: any custom emoji (`:white_check_mark:`, `:rocket:`, etc.) | Reaction: 6 fixed types only (`like`, `heart`, `laugh`, `surprised`, `sad`, `angry`) | Custom emoji reactions impossible | +| Thread discovery: `conversations.replies(channel, thread_ts)` | Graph API `GET /messages/{id}/replies` | Requires `ChannelMessage.Read.All` permission | +| *(no equivalent)* | `app.on('install.add')` | Bot installed event | +| *(no equivalent)* | `app.on('install.remove')` | Bot uninstalled event | +| *(no equivalent)* | `app.on('typing')` | User typing indicator | + +### Reaction workflow workaround: Adaptive Card buttons (R2) + +Replace Slack's custom emoji reaction workflows with explicit `Action.Submit` buttons on Adaptive Cards — the recommended Teams alternative. + +```typescript +// Slack (before): reaction-based approval +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "white_check_mark") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Approved by <@${event.user}>`, + thread_ts: event.item.ts, + }); + } +}); + +// Teams (after): button-based approval +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.action === "approve") { + return { + status: 200, + body: { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "TextBlock", + text: `Approved by ${activity.from?.name}`, + color: "Good", weight: "Bolder", + }], + // No actions = card becomes read-only + }, + }; + } +}); + +// Send the approval card (replaces posting a message users react to) +function buildApprovalCard(requestId: string): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [ + { type: "TextBlock", text: `Request #${requestId} needs approval`, weight: "Bolder" }, + ], + actions: [ + { type: "Action.Submit", title: "Approve", style: "positive", + data: { action: "approve", requestId } }, + { type: "Action.Submit", title: "Reject", style: "destructive", + data: { action: "reject", requestId } }, + ], + }; +} +``` + +**Why buttons are better:** Buttons provide explicit typed actions with an audit trail. Reactions are ambiguous (`:thumbsup:` vs `:+1:` vs `:white_check_mark:`) and produce no structured data. + +**Reverse (Teams → Slack):** Slack supports unlimited custom emoji — map directly or keep the button pattern (works on both platforms). + +### Thread broadcast helper (Y2) + +Slack's `reply_broadcast: true` sends a thread reply that also appears in the channel. Teams has no single-call equivalent — use a helper that makes both calls. + +```typescript +// Teams: replicate reply_broadcast behavior +async function replyWithBroadcast( + ctx: { reply: (text: string) => Promise; send: (text: string) => Promise }, + text: string +): Promise { + await ctx.reply(text); // threaded reply + await ctx.send(text); // also post to channel +} + +// Usage in a handler +app.on("message", async (ctx) => { + if (ctx.activity.text?.includes("broadcast")) { + await replyWithBroadcast(ctx, "This appears in both the thread and the channel."); + } +}); +``` + +**Don't:** Try to batch into a single API call — Teams doesn't support it. Two calls is the correct pattern. + +**Reverse (Teams → Slack):** Use `say({ text, thread_ts: message.ts, reply_broadcast: true })` natively — single call. + +### Thread discovery via Graph API (Y3) + +Fetching thread replies in Teams requires the Graph API, unlike Slack's simple `conversations.replies()`. + +```typescript +import { Client } from "@microsoft/microsoft-graph-client"; + +async function getThreadReplies( + graphClient: Client, + teamId: string, + channelId: string, + messageId: string, + top: number = 50 +): Promise { + const response = await graphClient + .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`) + .top(top) + .get(); + return response.value; +} + +// Usage in a handler (requires ChannelMessage.Read.All application permission) +app.on("message", async ({ activity, send }) => { + if (activity.text?.match(/^\/?replies/i)) { + const replies = await getThreadReplies( + graphClient, + activity.channelData?.teamsTeamId, + activity.channelData?.teamsChannelId, + activity.conversation?.id?.split(";")[0] ?? "" + ); + await send(`Found ${replies.length} replies in this thread.`); + } +}); +``` + +**Watch out for:** `ChannelMessage.Read.All` is an application permission requiring admin consent. If you only need replies in the bot's own conversations, delegated permissions may suffice. + +**Reverse (Teams → Slack):** Use `conversations.replies({ channel, ts: thread_ts })` natively — no special permissions needed. + +### RSC permission for all channel messages (Y16) + +Add RSC permission to the Teams manifest so the bot receives all channel messages without @mention — matching Slack's default behavior. + +```json +{ + "webApplicationInfo": { + "id": "{{CLIENT_ID}}", + "resource": "api://{{CLIENT_ID}}" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +Also strip @mention text from messages that do include a mention: + +```typescript +const app = new App({ + // ... other options + activity: { mentions: { stripText: true } }, +}); +``` + +**Don't:** Change your UX to require @mention unless your bot genuinely shouldn't listen to all messages. + +**Reverse (Teams → Slack):** Slack bots receive all messages in channels they're added to by default — no config needed. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, reverse the mapping -- Teams routes map back to Slack events: +- `app.on('message')` → `app.message(async ...)` catch-all or `app.event('app_mention')` if handling @mentions specifically +- `app.message(pattern)` → `app.message(pattern)` (direct equivalent) +- `app.on('conversationUpdate')` + `membersAdded` → `app.event('member_joined_channel')` +- `app.on('conversationUpdate')` + `membersRemoved` → `app.event('member_left_channel')` +- `app.on('messageReaction')` + `reactionsAdded` → `app.event('reaction_added')` -- note Teams has 6 fixed types; Slack supports unlimited custom emoji +- `app.on('messageReaction')` + `reactionsRemoved` → `app.event('reaction_removed')` +- `app.on('messageUpdate')` → `app.event('message_changed')` +- `app.on('messageDelete')` → `app.event('message_deleted')` +- `app.on('install.add')` → no direct Slack equivalent (use `app_home_opened` or OAuth completion callback for welcome messages) +- `send(text)` → `say(text)` +- `reply(text)` → `say({ text, thread_ts: message.ts })` +- Add `ack()` calls to Slack event handlers where required +- Slack bots receive all channel messages by default (no @mention required) -- adjust UX expectations accordingly + +## pitfalls + +- **Assuming all channel messages are delivered**: In Teams channels, bots only receive messages when @mentioned. This is the biggest behavioral difference from Slack. Design accordingly or use RSC permissions for broader message access. +- **Missing ephemeral message redesign**: Code that uses `respond({ response_type: 'ephemeral' })` will not work in Teams. Identify all ephemeral patterns early and plan alternative UX (personal chat, card refresh, or visible messages). +- **Not filtering the bot from `membersAdded`**: The `conversationUpdate` event fires when the bot itself is added. Always check `member.id !== activity.recipient?.id` to avoid the bot welcoming itself. +- **Thread model differences**: Slack threads use `thread_ts` on individual messages. Teams threaded replies use `reply()` or `replyToId`. The nesting model is similar but the API is different. +- **Reaction type mismatch**: Slack reactions use emoji names (e.g., `"eyes"`, `"thumbsup"`). Teams reactions use a limited set of types (`"like"`, `"heart"`, `"laugh"`, `"surprised"`, `"sad"`, `"angry"`). Custom emoji reactions do not exist in Teams. +- **Event handler context shape**: Slack event handlers receive `{ event, say, client }`. Teams handlers receive `{ activity, send, reply, stream }`. Do not try to destructure Slack property names from Teams handlers. +- **No `client` equivalent for arbitrary API calls**: Slack's `client.chat.postMessage()` for posting to other channels maps to `app.send(conversationId, text)` in Teams. Store conversation IDs at install time for proactive messaging. +- **Reaction-based workflows break silently**: A Slack bot using `:white_check_mark:` reactions as approval triggers will not error in Teams — it simply never fires because the custom emoji doesn't exist. Audit all `reaction_added` handlers for custom emoji names before migration. +- **No `reply_broadcast` equivalent**: Slack's "also send to channel" flag on threaded replies has no Teams counterpart. If the bot relies on broadcasting thread replies to the main channel, you must send two separate messages: a `reply()` to the thread and a `send()` to the channel. +- **Thread discovery requires Graph API with app permissions**: Fetching thread replies in Teams requires calling the Graph API (`/messages/{id}/replies`) with `ChannelMessage.Read.All` application-level permission. This is a significant permission escalation compared to Slack's `conversations.replies` which uses the standard bot token. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs +- https://github.com/microsoft/teams.ts +- https://slack.dev/bolt-js/concepts/events +- https://slack.dev/bolt-js/concepts/message-listening + +## instructions + +This expert covers bridging Slack event subscriptions and Teams activity handlers. Use it when adding cross-platform support in either direction: mapping Slack events (app_mention, member_joined_channel, member_left_channel, reaction_added, reaction_removed, message_changed, message_deleted, app_home_opened) to their Teams equivalents (message, conversationUpdate, messageReaction, messageUpdate, messageDelete, tab.open, install.add) or vice versa; converting between `say()`/`send()` and `reply()`/threaded patterns; handling ephemeral message differences; understanding the @mention requirement in Teams channels vs Slack's default all-message delivery; and mapping event payload properties between platforms. The comprehensive mapping table and reverse-direction section provide a quick reference for bridging in both directions. Pair with `../slack/runtime.bolt-foundations-ts.md` for Slack event patterns, and `../teams/runtime.routing-handlers-ts.md` for Teams activity routes. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack events and Teams activity routes bidirectionally. Cover all major Slack events (app_mention, member_joined_channel, member_left_channel, reaction_added, reaction_removed, message subtypes, app_home_opened) with their Teams equivalents (message, conversationUpdate, messageReaction, messageUpdate, messageDelete, typing, install events) and vice versa. Include side-by-side TypeScript code examples, a comprehensive bidirectional mapping table, payload shape differences, the @mention requirement in channels, ephemeral message handling strategies, and common pitfalls for both directions." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/files-upload-download-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/files-upload-download-ts.md new file mode 100644 index 000000000..6f5bb4607 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/files-upload-download-ts.md @@ -0,0 +1,332 @@ +# files-upload-download-ts + +## purpose + +Bridges Slack file operations (files.upload, file events) and Teams file consent / OneDrive/SharePoint patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `files.upload` → Teams FileConsentCard + Graph API upload.** Slack bots upload files directly via `files.upload`. Teams bots cannot directly attach files to messages. Instead: (a) send a FileConsentCard asking the user for upload consent, (b) on consent, upload the file to the user's OneDrive via Graph API, (c) send a file info card with the download link. [learn.microsoft.com -- Send files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +2. **The `supportsFiles: true` manifest flag is required.** Without `"supportsFiles": true` in the bot's manifest entry, Teams will not show file consent cards or allow the bot to handle file-related activities. This flag only works in personal (1:1) scope. [learn.microsoft.com -- Bot manifest](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#bots) +3. **Slack `files.sharedPublicURL` → Graph API `createLink` sharing link.** Slack creates a public URL for a file. In Teams/OneDrive, use the Graph API `POST /drives/{drive-id}/items/{item-id}/createLink` to create a sharing link with the desired permission scope (view, edit, anonymous). [learn.microsoft.com -- Create sharing link](https://learn.microsoft.com/en-us/graph/api/driveitem-createlink) +4. **Slack file events (`file_shared`, `file_created`) → `activity.attachments` in message handler.** When a user sends a file to a Teams bot, the file appears as an attachment on the incoming message activity. Check `activity.attachments` for items with `contentType` of `application/vnd.microsoft.teams.file.download.info`. [learn.microsoft.com -- Receive files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4#receive-files-in-personal-chat) +5. **Download user-uploaded files via the `downloadUrl` in the attachment.** Each file attachment includes a `content.downloadUrl` with a pre-authenticated URL. Use `fetch()` or `axios` to download the file content. The URL is short-lived — download immediately in the handler. [learn.microsoft.com -- File download](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +6. **File upload in channels requires SharePoint, not OneDrive.** In personal chat, files go to the user's OneDrive. In channels, files go to the team's SharePoint document library. The Graph API path changes: `POST /drives/{drive-id}/root:/{folder}/{filename}:/content` where the drive is the channel's SharePoint drive. [learn.microsoft.com -- SharePoint files](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content) +7. **Large files (>4 MB) require Graph resumable upload sessions.** Small files can use simple PUT to Graph API. Files larger than 4 MB must use a resumable upload session: `POST /drives/{drive-id}/items/{parent-id}:/filename:/createUploadSession`, then upload in 320 KB–60 MB chunks. Slack's `files.upload` handled this transparently. [learn.microsoft.com -- Resumable upload](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession) +8. **FileConsentCard flow is a 3-step protocol.** Step 1: Bot sends a FileConsentCard with filename and size. Step 2: User accepts or declines. Step 3: On accept, Teams sends a `fileConsent/invoke` activity with an `uploadInfo` containing the upload URL. On decline, Teams sends the same invoke with a `declined` action. Handle both cases. [learn.microsoft.com -- File consent](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4#send-files-to-personal-chat) +9. **Slack `files.list` / `files.info` → Graph API drive item queries.** Slack has dedicated file listing APIs. In Teams, files are stored in OneDrive/SharePoint. Use Graph API: `GET /drives/{drive-id}/root/children` to list files, `GET /drives/{drive-id}/items/{item-id}` for file metadata. [learn.microsoft.com -- List items](https://learn.microsoft.com/en-us/graph/api/driveitem-list-children) +10. **File handling only works in personal (1:1) chat scope.** The `supportsFiles` manifest flag and FileConsentCard only work in personal bot conversations. For channel file operations, use Graph API directly without the consent card flow. This is a significant scope limitation compared to Slack where `files.upload` works in any channel. [learn.microsoft.com -- Bot files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, the reverse is simpler: Slack's `files.uploadV2` is a direct single-call API vs Teams' multi-step consent flow. Map OneDrive/SharePoint file URLs to `files.uploadV2` with a buffer, Graph `createLink` sharing links to `files.sharedPublicURL`, and `activity.attachments` file downloads to Slack `file_shared` event handling. The Slack API handles storage transparently. + +## patterns + +### Upload a file with FileConsentCard (replaces files.upload) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; +import fs from "fs"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/export", async ({ ack, command, client }) => { + await ack(); + const csvData = await generateReport(); + + // Direct file upload — Slack handles storage + await client.files.uploadV2({ + channel_id: command.channel_id, + filename: "report.csv", + file: Buffer.from(csvData), + title: "Monthly Report", + initial_comment: "Here's your report!", + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store pending uploads keyed by conversation +const pendingUploads = new Map(); + +// Step 1: Send FileConsentCard (replaces files.upload) +app.message(/^\/?export$/i, async ({ send, activity }) => { + const csvData = await generateReport(); + const csvBuffer = Buffer.from(csvData); + const convId = activity.conversation?.id ?? ""; + + // Store the file content for later upload + pendingUploads.set(convId, csvBuffer); + + // Send consent card — user must approve the upload + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.consent", + name: "report.csv", + content: { + description: "Monthly Report — click Accept to save to your OneDrive", + sizeInBytes: csvBuffer.length, + acceptContext: { filename: "report.csv" }, + declineContext: { filename: "report.csv" }, + }, + }], + }); +}); + +// Step 2: Handle consent response +app.on("fileConsent" as any, async ({ activity, send }) => { + const action = activity.value?.action; + const convId = activity.conversation?.id ?? ""; + + if (action === "accept") { + // Step 3: Upload file to the URL provided by Teams + const uploadInfo = activity.value?.uploadInfo; + const fileContent = pendingUploads.get(convId); + + if (uploadInfo && fileContent) { + // Upload to OneDrive via the pre-signed URL + await fetch(uploadInfo.uploadUrl, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body: fileContent, + }); + + // Send confirmation with file card + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.info", + name: "report.csv", + contentUrl: uploadInfo.contentUrl, + content: { + uniqueId: uploadInfo.uniqueId, + fileType: "csv", + }, + }], + }); + } + pendingUploads.delete(convId); + } else { + await send("File upload cancelled."); + pendingUploads.delete(convId); + } +}); + +async function generateReport(): Promise { + return "Name,Status\nServer1,OK\nServer2,Down"; +} + +app.start(3978); +``` + +### Receive and process user-uploaded files + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.event("file_shared", async ({ event, client }) => { + const fileInfo = await client.files.info({ file: event.file_id }); + const file = fileInfo.file!; + + // Download file content using the private URL + bot token + const response = await fetch(file.url_private!, { + headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` }, + }); + const content = await response.text(); + + await client.chat.postMessage({ + channel: event.channel_id, + text: `Received ${file.name} (${file.size} bytes). Processing...`, + }); + + // Process the file content... +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Files arrive as attachments on regular message activities +app.on("message", async ({ activity, send }) => { + const fileAttachments = activity.attachments?.filter( + (a: any) => a.contentType === "application/vnd.microsoft.teams.file.download.info" + ); + + if (fileAttachments?.length) { + for (const attachment of fileAttachments) { + const downloadUrl = attachment.content?.downloadUrl; + const fileName = attachment.name; + + if (downloadUrl) { + // Download using the pre-authenticated URL (no token needed) + const response = await fetch(downloadUrl); + const content = await response.text(); + + await send(`Received ${fileName} (${content.length} chars). Processing...`); + // Process the file content... + } + } + } +}); + +app.start(3978); +``` + +### Reusable `sendFile()` helper (Y4/5/6 best practice) + +Build a unified helper that auto-detects personal vs. channel context and handles chunking. This eliminates the 30-line FileConsentCard footgun. + +```typescript +import { Client } from "@microsoft/microsoft-graph-client"; + +interface SendFileOptions { + filename: string; + content: Buffer; + description?: string; +} + +async function sendFile( + ctx: { send: (msg: any) => Promise; activity: any }, + graphClient: Client, + options: SendFileOptions +): Promise { + const { filename, content, description } = options; + const conversationType = ctx.activity.conversation?.conversationType; + + if (conversationType === "personal") { + // Personal chat → FileConsentCard flow + await ctx.send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.consent", + name: filename, + content: { + description: description ?? filename, + sizeInBytes: content.length, + acceptContext: { filename, size: content.length }, + declineContext: { filename }, + }, + }], + }); + // Store content for the fileConsent handler to pick up + pendingUploads.set(ctx.activity.conversation?.id ?? "", { content, filename }); + } else { + // Channel → Direct Graph API upload to SharePoint + const teamId = ctx.activity.channelData?.teamsTeamId; + const channelId = ctx.activity.channelData?.teamsChannelId; + const driveId = await getChannelDriveId(graphClient, teamId, channelId); + + if (content.length <= 4 * 1024 * 1024) { + // Small file: simple PUT + await graphClient + .api(`/drives/${driveId}/root:/${filename}:/content`) + .put(content); + } else { + // Large file (>4 MB): resumable upload session + const session = await graphClient + .api(`/drives/${driveId}/root:/${filename}:/createUploadSession`) + .post({ item: { name: filename } }); + + const chunkSize = 320 * 1024; // 320 KB chunks + for (let offset = 0; offset < content.length; offset += chunkSize) { + const chunk = content.subarray(offset, offset + chunkSize); + const end = Math.min(offset + chunkSize, content.length); + await fetch(session.uploadUrl, { + method: "PUT", + headers: { + "Content-Range": `bytes ${offset}-${end - 1}/${content.length}`, + "Content-Type": "application/octet-stream", + }, + body: chunk, + }); + } + } + + await ctx.send(`File uploaded: ${filename}`); + } +} + +async function getChannelDriveId( + graphClient: Client, teamId: string, channelId: string +): Promise { + const response = await graphClient + .api(`/teams/${teamId}/channels/${channelId}/filesFolder`) + .get(); + return response.parentReference.driveId; +} +``` + +**Key decisions:** +- Personal chat → FileConsentCard flow (requires `supportsFiles: true` in manifest) +- Channel → Direct Graph API upload to SharePoint (no consent card) +- Files >4 MB → Graph resumable upload session with 320 KB chunks + +**Don't:** Store pending file buffers in memory for long periods. Upload promptly or stream to a temporary blob. + +**Reverse (Teams → Slack):** Use `files.uploadV2({ channel_id, file: buffer, filename })` — single call, no consent step. + +### File operation mapping table + +| Slack API | Teams Equivalent | Notes | +|---|---|---| +| `files.uploadV2(channel, file)` | FileConsentCard → Graph PUT | 3-step consent flow; personal chat only | +| `files.sharedPublicURL(file)` | Graph `createLink(type, scope)` | Creates OneDrive/SharePoint sharing link | +| `files.info(file_id)` | Graph `GET /drives/{id}/items/{id}` | File metadata from OneDrive/SharePoint | +| `files.list(channel)` | Graph `GET /drives/{id}/root/children` | List drive items | +| `file_shared` event | `activity.attachments` check in message handler | No dedicated event; check attachments on each message | +| `file.url_private` + bot token | `attachment.content.downloadUrl` | Pre-authenticated URL; no token needed | +| Large file upload | Graph resumable upload session | Required for files > 4 MB | + +## pitfalls + +- **Missing `supportsFiles: true` in manifest**: Without this flag, Teams will not render FileConsentCards and file-related invoke activities will never fire. This is the #1 cause of "file upload doesn't work" during migration. +- **FileConsentCard only works in personal (1:1) chat**: Channel bots cannot use the consent card flow. For channel file operations, upload directly via Graph API to the team's SharePoint document library — which requires different Graph API permissions and paths. +- **Download URLs are short-lived**: The `downloadUrl` in file attachments is pre-authenticated but expires. Download the file immediately in the message handler. Do not store the URL for later use. +- **Large file upload requires chunking**: Files over 4 MB cannot use simple PUT. You must create an upload session and send chunks. Slack's `files.upload` handled this transparently — Teams requires explicit chunking logic. +- **Graph API permissions required**: File operations via Graph API require `Files.ReadWrite` (delegated) or `Files.ReadWrite.All` (application) permissions. These must be configured in the Azure AD app registration and consented by an admin for application permissions. +- **No file preview in bot messages**: Slack generates inline previews for uploaded images and documents. Teams file info cards show a file icon and name but not an inline preview. For image files, consider embedding the image URL directly in an Adaptive Card `Image` element instead. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4 +- https://learn.microsoft.com/en-us/graph/api/driveitem-put-content +- https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession +- https://learn.microsoft.com/en-us/graph/api/driveitem-createlink +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#bots +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/files.uploadV2 — Slack files.upload +- https://api.slack.com/methods/files.sharedPublicURL — Slack file sharing + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack file operations or Teams file consent / OneDrive/SharePoint patterns. It covers: `files.upload` to FileConsentCard + Graph upload, `files.sharedPublicURL` to Graph sharing links, file event handling via activity attachments, large file resumable uploads, and the personal-chat-only limitation. For Teams → Slack, the reverse is simpler: Slack's `files.uploadV2` is a direct single-call API vs Teams' multi-step consent flow. Pair with `../teams/graph.usergraph-appgraph-ts.md` for Graph API authentication patterns, `../teams/runtime.manifest-ts.md` for the `supportsFiles` manifest flag, and `interactive-responses-ts.md` for the consent card invoke handling pattern. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack file operations (files.upload, files.sharedPublicURL, file_shared event, file download) and Teams file consent / OneDrive/SharePoint patterns in either direction for cross-platform bots. Cover FileConsentCard 3-step flow, OneDrive/SharePoint Graph API uploads, resumable upload sessions for large files, receiving files via activity attachments, the supportsFiles manifest flag, personal-chat-only limitation, Graph API permission requirements, and reverse-direction notes for Teams → Slack (simpler single-call API). Include TypeScript code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/identity-oauth-bridge-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/identity-oauth-bridge-ts.md new file mode 100644 index 000000000..a8f45f155 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/identity-oauth-bridge-ts.md @@ -0,0 +1,354 @@ +# identity-oauth-bridge-ts + +## purpose + +Bridges Slack and Teams/Azure AD identity systems (user/channel IDs, OAuth, signing) for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack uses proprietary ID formats: user IDs start with `U` (e.g., `U01ABCDEF`), channel IDs start with `C` (e.g., `C02GHIJKL`), team/workspace IDs start with `T` (e.g., `T03MNOPQR`), and bot IDs start with `B`. These IDs have no relationship to Teams/Azure AD identifiers and cannot be mapped automatically. [api.slack.com/types](https://api.slack.com/types) +2. Teams identifies users by Azure AD Object ID (a GUID like `00000000-0000-0000-0000-000000000000`), available at `activity.from.aadObjectId`. Conversation IDs are opaque strings like `19:abc123@thread.v2` for channels or `a]concat@...` for personal chats. These formats are fundamentally different from Slack IDs. [learn.microsoft.com -- Activity schema](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +3. Slack's request verification via `signingSecret` (HMAC-SHA256 of request body) is replaced by **Bot Framework JWT token validation** in Teams. The Teams SDK handles JWT validation automatically -- no manual signing secret check is needed. [learn.microsoft.com -- Bot authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication) +4. Slack's bot token (`xoxb-...`) used for API calls is replaced by **Azure Bot credentials** (`CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID`). The Teams SDK uses these to obtain tokens for the Bot Framework service automatically. [learn.microsoft.com -- Register a bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) +5. Slack OAuth scopes (e.g., `chat:write`, `users:read`, `commands`) map to **Azure AD permissions** for the Microsoft Graph API (e.g., `User.Read`, `ChannelMessage.Send`). Slack scopes are configured in the Slack app dashboard; Azure AD permissions are configured in the Azure Portal under App Registration > API Permissions. [learn.microsoft.com -- Graph permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) +6. Slack user tokens (obtained via OAuth `users:read` or user token grant) map to **Teams SSO / OAuth card flow**. In Teams, configure an OAuth connection in the Azure Bot resource, then use `isSignedIn` / `signin()` / `userGraph` in handlers to access the user's delegated token for Graph API calls. [learn.microsoft.com -- Bot SSO](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/bot-sso-overview) +7. To resolve user identity across platforms during migration, use a **shared attribute** like email address. Query Slack's `users.info` API for the user's email, then look up the same email in Azure AD via Microsoft Graph `users?$filter=mail eq '...'`. Build a mapping table of Slack user ID to AAD Object ID. [learn.microsoft.com -- Graph users API](https://learn.microsoft.com/en-us/graph/api/user-list) +8. Any data stored with Slack IDs as keys (user preferences, conversation history, permissions) must be **re-keyed** to Teams/AAD IDs. Plan a data migration step that uses the email-based mapping table to translate stored Slack user IDs to AAD Object IDs. [learn.microsoft.com -- Migration planning](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) +9. Slack workspace-level operations (e.g., listing all users via `users.list`, posting to any channel) require bot scopes. In Teams, equivalent operations use Microsoft Graph with **application permissions** (consented by a tenant admin). Use `appGraph` for service-to-service calls and `userGraph` for delegated user calls. [learn.microsoft.com -- Graph auth overview](https://learn.microsoft.com/en-us/graph/auth/auth-concepts) +10. Teams supports **managed identity** as an alternative to client secret for production deployments on Azure. Set `managedIdentityClientId: 'system'` in App options to use Azure Managed Identity instead of storing secrets in environment variables. [learn.microsoft.com -- Managed identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) + +## patterns + +### Environment variable mapping between Slack and Teams + +**Slack `.env`:** + +```env +# Slack Bot Configuration +SLACK_BOT_TOKEN=your-slack-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_APP_TOKEN=your-slack-app-token +SLACK_CLIENT_ID=your-slack-client-id +SLACK_CLIENT_SECRET=your-slack-client-secret +PORT=3000 +``` + +**Teams `.env`:** + +```env +# Azure Bot Registration +CLIENT_ID=00000000-0000-0000-0000-000000000000 +CLIENT_SECRET=your-azure-bot-client-secret +TENANT_ID=00000000-0000-0000-0000-000000000000 +PORT=3978 +``` + +**Environment variable mapping table:** + +| Slack Variable | Teams Variable | Notes | +|---|---|---| +| `SLACK_BOT_TOKEN` (`xoxb-...`) | `CLIENT_ID` + `CLIENT_SECRET` | Teams SDK manages token acquisition automatically | +| `SLACK_SIGNING_SECRET` | *(not needed)* | Bot Framework JWT validation is automatic | +| `SLACK_APP_TOKEN` (`xapp-...`) | *(not needed)* | Socket mode is Slack-only; Teams uses HTTPS | +| `SLACK_CLIENT_ID` | `CLIENT_ID` | Azure Bot App Registration ID (GUID) | +| `SLACK_CLIENT_SECRET` | `CLIENT_SECRET` | Azure Bot App Registration secret | +| *(not applicable)* | `TENANT_ID` | Azure AD tenant ID (new for Teams) | +| `PORT` (default 3000) | `PORT` (default 3978) | Different conventional defaults | + +**Identity concept mapping table:** + +| Slack Concept | Teams/Azure AD Concept | Format | +|---|---|---| +| User ID (`U01ABCDEF`) | AAD Object ID | GUID (`00000000-...`) | +| Channel ID (`C02GHIJKL`) | Conversation ID | `19:abc@thread.v2` | +| Team/Workspace ID (`T03MNOPQR`) | Tenant ID | GUID | +| Bot ID (`B04STUVWX`) | Bot ID (from App Registration) | GUID | +| DM Channel ID (`D05YZABCD`) | Personal conversation ID | Opaque string | +| Signing Secret | Bot Framework JWT | Automatic validation | +| Bot Token (`xoxb-...`) | Client credentials flow | CLIENT_ID + CLIENT_SECRET | +| User Token (`xoxp-...`) | Delegated OAuth token | SSO / OAuth card flow | +| OAuth scopes (`chat:write`) | Azure AD permissions (`ChannelMessage.Send`) | Configured in Azure Portal | +| Slack App Dashboard | Azure Portal + manifest.json | Config split between portal and file | + +### Migrating authentication from Slack OAuth to Teams SSO + +**Slack (before) -- Using Slack OAuth for user identity:** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/whoami", async ({ ack, command, client }) => { + await ack(); + // Use the bot token to look up user info + const userInfo = await client.users.info({ user: command.user_id }); + const email = userInfo.user?.profile?.email ?? "unknown"; + const name = userInfo.user?.real_name ?? "unknown"; + await client.chat.postMessage({ + channel: command.channel_id, + text: `You are ${name} (${email})`, + }); +}); +``` + +**Teams (after) -- Using Teams SSO and Microsoft Graph:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; +import * as endpoints from "@microsoft/teams.graph-endpoints"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], + oauth: { defaultConnectionName: "graph" }, +}); + +app.message(/^\/?whoami$/i, async ({ isSignedIn, signin, userGraph, send }) => { + // If user is not signed in, trigger the SSO/OAuth flow + if (!isSignedIn) { + await signin({ signInButtonText: "Sign In to continue" }); + return; + } + + // Use the delegated Graph client to get user profile + const me = await userGraph.call(endpoints.me.get); + await send(`You are ${me.displayName} (${me.mail})`); +}); + +// Handle successful sign-in +app.event("signin", async ({ send, userGraph }) => { + const me = await userGraph.call(endpoints.me.get); + await send(`Welcome, ${me.displayName}! You are now signed in.`); +}); + +app.start(3978); +``` + +### Building a Slack-to-AAD user ID mapping table + +```typescript +import { WebClient } from "@slack/web-api"; +import { Client as GraphClient } from "@microsoft/microsoft-graph-client"; + +interface UserMapping { + slackUserId: string; + slackEmail: string; + aadObjectId: string | null; + aadDisplayName: string | null; +} + +async function buildUserMappingTable( + slackClient: WebClient, + graphClient: GraphClient +): Promise { + const mappings: UserMapping[] = []; + + // Step 1: Fetch all Slack users + const slackUsers = await slackClient.users.list({}); + const members = slackUsers.members ?? []; + + for (const slackUser of members) { + if (slackUser.deleted || slackUser.is_bot) continue; + + const email = slackUser.profile?.email; + if (!email) { + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: "", + aadObjectId: null, + aadDisplayName: null, + }); + continue; + } + + // Step 2: Look up the same email in Azure AD via Graph + try { + const result = await graphClient + .api("/users") + .filter(`mail eq '${email}' or userPrincipalName eq '${email}'`) + .select("id,displayName,mail") + .get(); + + const aadUser = result.value?.[0]; + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: email, + aadObjectId: aadUser?.id ?? null, + aadDisplayName: aadUser?.displayName ?? null, + }); + } catch { + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: email, + aadObjectId: null, + aadDisplayName: null, + }); + } + } + + return mappings; +} + +// Step 3: Use the mapping to re-key stored data +async function migrateUserData( + mappings: UserMapping[], + oldStore: Map, + newStore: Map +): Promise { + for (const mapping of mappings) { + if (!mapping.aadObjectId) continue; + const data = oldStore.get(mapping.slackUserId); + if (data) { + newStore.set(mapping.aadObjectId, data); + } + } +} +``` + +### Converting Slack OAuth implementation code to Teams OAuth + +Slack SDKs (especially `java-slack-sdk` and `@slack/bolt`) implement OAuth with explicit services: `InstallationService` for storing tokens, `OAuthStateService` for CSRF, and `OAuthCallbackHandler` for the redirect. Teams replaces ALL of this with declarative config. + +**Slack Java SDK OAuth (before):** + +```java +// --- Slack Java SDK OAuth implementation --- +// InstallationService — stores bot tokens per workspace +public class FileInstallationService implements InstallationService { + public void saveInstallerAndBot(Installer installer) { /* persist to DB */ } + public Installer findInstaller(String enterpriseId, String teamId) { /* lookup */ } + public Bot findBot(String enterpriseId, String teamId) { /* lookup */ } + public void deleteBot(Bot bot) { /* remove */ } + public void deleteInstaller(Installer installer) { /* remove */ } +} + +// OAuthStateService — generates and validates CSRF state parameter +public class FileOAuthStateService implements OAuthStateService { + public String issueNewState(Request req) { /* generate random state */ } + public boolean isValid(OAuthState state) { /* validate state */ } + public void consume(OAuthState state) { /* mark used */ } +} + +// App configuration with OAuth +App app = new App(AppConfig.builder() + .clientId(System.getenv("SLACK_CLIENT_ID")) + .clientSecret(System.getenv("SLACK_CLIENT_SECRET")) + .signingSecret(System.getenv("SLACK_SIGNING_SECRET")) + .oAuthInstallPath("/slack/install") + .oAuthRedirectUriPath("/slack/oauth_redirect") + .oAuthCompletionUrl("https://example.com/success") + .oAuthCancellationUrl("https://example.com/cancel") + .installationService(new FileInstallationService()) + .oauthStateService(new FileOAuthStateService()) + .build()); +``` + +**Teams OAuth (after):** + +```typescript +// --- Teams OAuth — all of the above is replaced by config --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), + + // This single config block replaces: + // - InstallationService (token storage is managed by Azure Bot Service) + // - OAuthStateService (CSRF handled by Bot Framework) + // - OAuthCallbackHandler (redirect handled by Azure Bot Service) + // - Token refresh logic (managed by Azure Bot Service) + oauth: { + defaultConnectionName: 'graph', // configured in Azure Portal + // That's it. No custom services needed. + }, +}); + +// Instead of Slack's multi-step OAuth flow with custom storage: +// 1. Azure Bot Service manages token acquisition, refresh, and storage +// 2. CSRF protection is built into the Bot Framework sign-in flow +// 3. The OAuth connection is configured in Azure Portal (not code) +// 4. Use isSignedIn/signin() in handlers to trigger auth when needed + +app.message(/^profile$/i, async ({ isSignedIn, signin, userGraph, send }) => { + if (!isSignedIn) { + await signin(); + return; + } + const me = await userGraph.call(endpoints.me.get); + await send(`Signed in as ${me.displayName}`); +}); +``` + +**What gets DELETED during conversion:** + +| Slack OAuth Component | Teams Equivalent | Action | +|---|---|---| +| `InstallationService` + DB storage | Azure Bot Service token cache | Delete entirely | +| `OAuthStateService` + CSRF tokens | Bot Framework built-in CSRF | Delete entirely | +| `OAuthCallbackHandler` + redirect routes | Azure Bot Service callbacks | Delete entirely | +| Token refresh / expiry logic | Azure Bot Service auto-refresh | Delete entirely | +| `/slack/install` route | Teams app install flow | Delete entirely | +| `/slack/oauth_redirect` route | Azure Bot Service | Delete entirely | +| Multi-workspace token lookup | Managed identity / tenant config | Delete entirely | +| Slack OAuth scopes in code | Azure Portal API Permissions | Configure in portal | + +### Reverse direction (Teams → Slack) + +For Teams → Slack, the same mapping table applies in reverse. AAD Object IDs need mapping to Slack user IDs via email lookup. Key reverse mappings: +- `activity.from.aadObjectId` (GUID) → Slack User ID (`U...`) via email-based lookup: query Graph `users/{aadObjectId}` for email, then `users.lookupByEmail` in Slack +- `activity.conversation.id` (`19:abc@thread.v2`) → Slack Channel ID (`C...`) via channel name mapping or a stored lookup table +- `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` → `SLACK_BOT_TOKEN` (`xoxb-...`) + `SLACK_SIGNING_SECRET` +- Azure AD permissions (`ChannelMessage.Send`, `User.Read`) → Slack OAuth scopes (`chat:write`, `users:read`) +- Teams SSO / OAuth card flow → Slack OAuth with `InstallationService` and `OAuthStateService` (Slack requires explicit token storage and refresh logic that Azure Bot Service handles automatically) +- Bot Framework JWT validation (automatic) → Slack signing secret HMAC-SHA256 verification (must add `signingSecret` to Bolt config) +- Azure Managed Identity → no Slack equivalent; use environment variables or secret manager for Slack tokens +- The email-based user mapping table built for Slack → Teams works identically in reverse + +## pitfalls + +- **Assuming Slack IDs can be reused**: Slack IDs (`U...`, `C...`, `T...`) are completely incompatible with Teams/AAD IDs. Any code that stores or references Slack IDs must be updated to use AAD Object IDs and conversation IDs. +- **Manual signing secret validation**: Developers sometimes port Slack's HMAC verification middleware to Teams. This is unnecessary -- the Bot Framework validates JWT tokens automatically. Remove all signing secret verification code. +- **Expecting ephemeral identity context**: Slack's `user_id` is always present in command and action payloads. In Teams, `activity.from.aadObjectId` may be `undefined` in some contexts (e.g., webhook-originated activities). Always null-check. +- **OAuth scope confusion**: Slack scopes like `chat:write` do not map 1:1 to Azure AD permissions. Audit each Slack scope used and find the equivalent Graph permission. Some Slack capabilities require multiple Graph permissions or a different API approach entirely. +- **Storing tokens insecurely**: Slack bot tokens are long-lived strings. Azure Bot credentials use short-lived JWT tokens managed by the SDK. Never try to cache or store Bot Framework tokens manually. +- **Skipping the user mapping step**: Without building a Slack-to-AAD mapping table, any user-specific data (preferences, history, permissions) stored under Slack IDs becomes inaccessible. Plan this migration step early. +- **Tenant ID confusion**: Slack workspaces have a single team ID. Azure AD tenants can contain multiple Teams organizations. Ensure `TENANT_ID` is set correctly -- use the specific tenant ID for single-tenant apps or `common` for multi-tenant. +- **Forgetting to configure OAuth connection**: Teams SSO requires an OAuth connection configured in the Azure Bot resource (Settings > OAuth Connection Settings). Without it, `signin()` calls fail silently. +- **Porting OAuth implementation code instead of deleting it**: Slack's `InstallationService`, `OAuthStateService`, and `OAuthCallbackHandler` have NO Teams equivalent. Azure Bot Service handles token storage, CSRF, and callbacks automatically. Attempting to port these services wastes effort and introduces bugs. Delete them entirely and use the `oauth: { defaultConnectionName }` config. +- **Custom token refresh logic**: Slack apps often implement manual token refresh with `oauth.v2.access`. Azure Bot Service refreshes tokens automatically. Delete all refresh code. + +## references + +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/bot-sso-overview +- https://learn.microsoft.com/en-us/graph/permissions-reference +- https://learn.microsoft.com/en-us/graph/api/user-list +- https://learn.microsoft.com/en-us/graph/auth/auth-concepts +- https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +- https://api.slack.com/types +- https://api.slack.com/methods/users.info +- https://github.com/microsoft/teams.ts + +## instructions + +This expert covers bridging Slack and Teams/Azure AD identity and authentication systems. Use it when adding cross-platform support in either direction: understanding the differences between Slack IDs (U/C/T/B prefixed) and Teams IDs (AAD Object IDs, conversation IDs); bridging signing/verification (Slack signing secret ↔ Bot Framework JWT); mapping environment variables (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET ↔ CLIENT_ID, CLIENT_SECRET, TENANT_ID); converting between Slack OAuth and Teams SSO with Microsoft Graph; building a bidirectional user mapping table using email as the shared attribute; bridging Slack OAuth scopes and Azure AD permissions; and configuring authentication for either platform. Pair with `../teams/auth.oauth-sso-ts.md` for Teams OAuth/SSO flow, and `../teams/graph.usergraph-appgraph-ts.md` for Graph API user lookup during identity mapping. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack and Teams/Azure AD identity systems bidirectionally. Cover Slack ID formats (U/C/T/B IDs) vs Teams IDs (AAD Object IDs, conversation IDs), signing/verification bridging (signing secret <-> Bot Framework JWT), environment variable mapping in both directions, Slack OAuth <-> Teams SSO flow, Slack scopes <-> Azure AD Graph permissions, building a bidirectional user mapping table via email lookup, data re-keying strategies, and managed identity for production. Include mapping tables and TypeScript examples." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/index.md b/skills/microsoft-365-agents-toolkit/experts/bridge/index.md new file mode 100644 index 000000000..6f0259ef2 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/index.md @@ -0,0 +1,215 @@ +# bridge-router + +## purpose + +Route cross-platform bridging tasks to the minimal set of micro-expert files. Each expert covers bridging between Slack and Teams (or AWS and Azure) in either direction. Read only the clusters that match the user's request. + +## task clusters + +### Block Kit <-> Adaptive Cards +When: converting Block Kit JSON to Adaptive Card JSON or vice versa, mapping Slack blocks to card elements, mapping Adaptive Card elements to Block Kit blocks +Read: +- `ui-block-kit-adaptive-cards-ts.md` +Cross-domain deps: `../slack/ui.block-kit-ts.md` (Slack Block Kit patterns), `../teams/ui.adaptive-cards-ts.md` (Teams Adaptive Card patterns) + +### Commands: Slash <-> Text +When: bridging slash commands between Slack and Teams, command registration differences, porting commands in either direction +Read: +- `commands-slash-text-ts.md` +Cross-domain deps: `../slack/runtime.slash-commands-ts.md` (Slack command patterns), `../teams/runtime.routing-handlers-ts.md` (Teams app.message() patterns) + +### Events <-> Activities +When: mapping Slack events to Teams activity handlers or vice versa, event model differences +Read: +- `events-activities-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack event patterns), `../teams/runtime.routing-handlers-ts.md` (Teams activity routes) + +### Identity & OAuth Bridge +When: bridging Slack OAuth/identity and Azure AD/Entra ID, user mapping, SSO, OAuth implementation code (InstallationService, OAuthStateService, token refresh) +Read: +- `identity-oauth-bridge-ts.md` +Cross-domain deps: `../teams/auth.oauth-sso-ts.md` (Teams OAuth/SSO flow), `../teams/graph.usergraph-appgraph-ts.md` (Graph API for user lookup) + +### Middleware <-> Handlers +When: converting Slack Bolt middleware chains to Teams handler patterns or vice versa, porting global/listener middleware, removing or adding ack() +Read: +- `middleware-handlers-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack middleware patterns), `../teams/runtime.routing-handlers-ts.md` (Teams handler patterns) + +### Modals <-> Dialogs +When: bridging Slack modals (views.open, viewSubmission, viewsUpdate, viewClosed, blockSuggestion in modals) and Teams task module / dialog flows +Read: +- `ui-modals-dialogs-ts.md` +Cross-domain deps: `../teams/ui.dialogs-task-modules-ts.md` (Teams dialog patterns), `ui-block-kit-adaptive-cards-ts.md` (converting modal UI between Block Kit and Adaptive Cards) + +### App Home <-> Personal Tab +When: bridging Slack App Home tab (AppHomeOpenedEvent, views.publish) and Teams personal tab or bot welcome card +Read: +- `ui-app-home-personal-tab-ts.md` +Cross-domain deps: `events-activities-ts.md` (event mapping), `../teams/ui.adaptive-cards-ts.md` (card construction), `../teams/runtime.proactive-messaging-ts.md` (background updates) + +### Legacy Attachments <-> Cards +When: bridging pre-Block Kit legacy Slack attachments (callback_id, color, actions, attachmentAction) and Adaptive Cards +Read: +- `ui-legacy-attachments-cards-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (Teams card patterns) + +### Transport: Socket Mode <-> HTTPS +When: bridging Slack Socket Mode, RTM, or HTTP Events API and Teams Bot Framework HTTPS transport +Read: +- `transport-socketmode-https-ts.md` +Cross-domain deps: `../teams/runtime.app-init-ts.md` (Teams app startup), `../teams/dev.debug-test-ts.md` (ngrok/Dev Tunnels setup) + +### Infrastructure: Compute +When: bridging Lambda and Azure Functions, compute migration, serverless porting in either direction +Read: +- `infra-compute-ts.md` +- `infra-secrets-config-ts.md` (App Settings / env vars needed for compute config) + +### Infrastructure: Storage +When: bridging S3 and Blob Storage, DynamoDB and Cosmos DB, storage migration in either direction +Read: +- `infra-storage-ts.md` +Cross-domain deps: `../teams/state.storage-patterns-ts.md` (IStorage interface for bot state on Cosmos DB) + +### Infrastructure: Secrets & Config +When: bridging AWS Secrets Manager and Azure Key Vault, SSM and App Configuration +Read: +- `infra-secrets-config-ts.md` +Cross-domain deps: `../security/secrets-ts.md` (secrets management best practices) + +### Infrastructure: Observability +When: bridging CloudWatch and Application Insights, X-Ray and Azure Monitor, logging migration +Read: +- `infra-observability-ts.md` +Cross-domain deps: `../teams/dev.debug-test-ts.md` (Teams SDK logging with ConsoleLogger) + +### Interactive Responses +When: bridging respond({ replace_original }), respond({ delete_original }), chat.update, chat.postEphemeral, deferred responses, response_url patterns between Slack and Teams +Read: +- `interactive-responses-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (card construction), `../teams/runtime.proactive-messaging-ts.md` (deferred update infrastructure) + +### Files: Upload & Download +When: bridging files.upload, files.sharedPublicURL, file events, file download/upload patterns between platforms +Read: +- `files-upload-download-ts.md` +Cross-domain deps: `../teams/graph.usergraph-appgraph-ts.md` (Graph API auth), `../teams/runtime.manifest-ts.md` (supportsFiles flag) + +### Link Unfurl <-> Preview +When: bridging link_shared event and chat.unfurl() (Slack) with link preview cards (Teams) +Read: +- `link-unfurl-preview-ts.md` +Cross-domain deps: `../teams/ui.message-extensions-ts.md` (message extension patterns), `../teams/runtime.manifest-ts.md` (messageHandlers domain config) + +### Shortcuts <-> Extensions +When: bridging Slack global shortcuts and message shortcuts with Teams message extensions or compose extensions +Read: +- `shortcuts-extensions-ts.md` +Cross-domain deps: `../teams/ui.message-extensions-ts.md` (message extension patterns), `../teams/ui.dialogs-task-modules-ts.md` (task module details) + +### Scheduling & Deferred Send +When: bridging chat.scheduleMessage, chat.deleteScheduledMessage, reminders.add, timer-based patterns between platforms +Read: +- `scheduling-deferred-send-ts.md` +Cross-domain deps: `../teams/runtime.proactive-messaging-ts.md` (proactive send infrastructure), `../teams/state.storage-patterns-ts.md` (persisting scheduled items) + +### Channel Ops <-> Graph +When: bridging conversations.create, conversations.archive, conversations.invite, conversations.kick, conversations.setTopic via Graph API +Read: +- `channel-ops-graph-ts.md` +Cross-domain deps: `../teams/graph.usergraph-appgraph-ts.md` (Graph API auth), `identity-oauth-bridge-ts.md` (user ID mapping) + +### Workflows <-> Automation +When: bridging Slack Workflow Builder workflows, custom workflow steps (workflow_step_execute), and Power Automate flows +Read: +- `workflows-automation-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (card construction for bot-driven workflows), `../teams/runtime.proactive-messaging-ts.md` (flow-triggered bot messages) + +### Composable Workflow Platform +When: composable workflow architecture, reusable workflow engine, WorkflowDefinition, template workflows, five-element lifecycle, workflow platform design, workflow operating layer +Read: +- `workflow.composable-platform-ts.md` +Cross-domain deps: `../teams/workflow.sharepoint-lists-ts.md` (state), `../teams/workflow.message-native-records-ts.md` (visibility), `../teams/workflow.triggers-compose-ts.md` (triggers), `../teams/ai.conversational-query-ts.md` (intelligence), `../teams/workflow.approvals-inline-ts.md` (routing) + +### App Distribution & Packaging +When: bridging Slack App Directory listing, OAuth install flow, InstallationStore, org-level installs and Teams sideloading, app packaging, Teams Admin Center +Read: +- `app-distribution-packaging-ts.md` +Cross-domain deps: `identity-oauth-bridge-ts.md` (identity model bridge), `../teams/runtime.manifest-ts.md` (Teams manifest creation) + +### Rate Limiting & Resilience +When: bridging rate limiting patterns, retry logic, throttling handling, proactive broadcast resilience, circuit breaker between platforms +Read: +- `rate-limiting-resilience-ts.md` +Cross-domain deps: `../teams/runtime.proactive-messaging-ts.md` (proactive send infrastructure), `../teams/graph.usergraph-appgraph-ts.md` (Graph API throttling) + +### Cross-Platform Advisor +When: starting a cross-platform bridging project, assessing scope, making bridging decisions, "help me add Teams", "help me add Slack", "help me migrate", "what do I need to do to bridge" +Read: +- `cross-platform-advisor-ts.md` +Note: This expert orchestrates the full bridging workflow — it detects direction, scans the codebase, classifies the bot profile, walks through decisions, then routes to the individual experts above for implementation. + +### Cross-Platform Architecture +When: hosting both bots in a single server, shared Express, dual bot, single process, platform-agnostic service layer, deployment architecture +Read: +- `cross-platform-architecture-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack setup), `../teams/runtime.app-init-ts.md` (Teams setup) + +### Python Cross-Platform +When: Python dual-platform, Python unified server, `slack_bolt` + `microsoft_teams`, FastAPI shared server, Python Slack + Teams, Tier 2, Python adaptation +Read: +- `python-cross-platform.md` +Cross-domain deps: `../slack/bolt-python.md` (Slack Python SDK), `../teams/teams-python.md` (Teams Python SDK), `cross-platform-architecture-ts.md` (architecture patterns to adapt) + +### REST-Only Integration +When: Java, C#, Go, Ruby, no SDK, raw HTTP, Bot Framework REST API, Slack Events API, Slack Web API, manual JWT validation, manual signature verification, language without native SDK +Read: +- `rest-only-integration-ts.md` +Cross-domain deps: `cross-platform-architecture-ts.md` (if mixing REST with TS SDK) + +### Composite: Full Slack <-> Teams Bridge +When: complete end-to-end cross-platform bridging between Slack and Teams bots +Read: +- `ui-block-kit-adaptive-cards-ts.md` +- `commands-slash-text-ts.md` +- `events-activities-ts.md` +- `identity-oauth-bridge-ts.md` +- `middleware-handlers-ts.md` +- `transport-socketmode-https-ts.md` +- `ui-modals-dialogs-ts.md` +- `ui-app-home-personal-tab-ts.md` +- `ui-legacy-attachments-cards-ts.md` +- `interactive-responses-ts.md` +- `files-upload-download-ts.md` +- `link-unfurl-preview-ts.md` +- `shortcuts-extensions-ts.md` +- `scheduling-deferred-send-ts.md` +- `channel-ops-graph-ts.md` +- `workflows-automation-ts.md` +- `app-distribution-packaging-ts.md` +- `rate-limiting-resilience-ts.md` +Cross-domain deps: `../teams/project.scaffold-files-ts.md` (scaffold the new Teams project), `../teams/runtime.app-init-ts.md` (initialize the Teams app), `../teams/runtime.manifest-ts.md` (create the Teams manifest) + +### Composite: Full AWS <-> Azure Bridge +When: complete end-to-end infrastructure bridging between AWS and Azure +Read: +- `infra-compute-ts.md` +- `infra-storage-ts.md` +- `infra-secrets-config-ts.md` +- `infra-observability-ts.md` +Cross-domain deps: `../security/secrets-ts.md` (secrets hygiene for Azure) + +## combining rule + +If a request involves both Slack↔Teams app bridging **and** AWS↔Azure infra bridging, read files from **both** composite clusters. + +## file inventory + +`app-distribution-packaging-ts.md` | `channel-ops-graph-ts.md` | `workflow.composable-platform-ts.md` | `commands-slash-text-ts.md` | `cross-platform-advisor-ts.md` | `cross-platform-architecture-ts.md` | `events-activities-ts.md` | `files-upload-download-ts.md` | `identity-oauth-bridge-ts.md` | `infra-compute-ts.md` | `infra-observability-ts.md` | `infra-secrets-config-ts.md` | `infra-storage-ts.md` | `interactive-responses-ts.md` | `link-unfurl-preview-ts.md` | `middleware-handlers-ts.md` | `python-cross-platform.md` | `rate-limiting-resilience-ts.md` | `rest-only-integration-ts.md` | `scheduling-deferred-send-ts.md` | `shortcuts-extensions-ts.md` | `transport-socketmode-https-ts.md` | `ui-app-home-personal-tab-ts.md` | `ui-block-kit-adaptive-cards-ts.md` | `ui-legacy-attachments-cards-ts.md` | `ui-modals-dialogs-ts.md` | `workflows-automation-ts.md` + + + + + + diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/infra-compute-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-compute-ts.md new file mode 100644 index 000000000..26c3d6d31 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-compute-ts.md @@ -0,0 +1,232 @@ +# infra-compute-ts + +## purpose + +Bridges AWS and Azure compute infrastructure for cross-platform bot hosting. Covers Lambda/ECS/EC2 to Azure App Service/Functions/Container Apps (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: App Service → EC2/ECS, Azure Functions → Lambda + API Gateway, Container Apps → ECS/Fargate. + +## rules + +1. Map AWS compute services to Azure equivalents using this decision matrix: Lambda + API Gateway maps to Azure Functions (Consumption or Premium), ECS/Fargate maps to Azure Container Apps, EC2 maps to Azure App Service (or Azure VMs for lift-and-shift). Choose based on existing architecture and workload characteristics. [learn.microsoft.com -- Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) +2. Teams bots require an HTTPS endpoint at `/api/messages` that accepts POST requests from the Bot Framework. Azure App Service and Container Apps provide this natively; Azure Functions requires an HTTP-triggered function bound to that route. [learn.microsoft.com -- Bot messaging endpoint](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-basics) +3. Teams expects bot responses within 3 seconds for synchronous invoke activities (card actions, dialogs, message extensions). Azure Functions Consumption plan cold starts (5-10 seconds for Node.js) will violate this. Use the Premium plan (pre-warmed instances) or App Service (always-on) for production Teams bots. [learn.microsoft.com -- Functions Premium](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan) +4. Enable "Always On" for Azure App Service deployments to prevent the app from unloading after idle periods. Without it, the first request after idle triggers a cold start that can cause Teams timeouts. Set this in Configuration > General Settings or via CLI. [learn.microsoft.com -- App Service Always On](https://learn.microsoft.com/en-us/azure/app-service/configure-common) +5. Use Node.js 20 LTS or later as the runtime stack. Set this explicitly in App Service (Configuration > General Settings > Stack: Node, Version: 20-lts) or in the Azure Functions `host.json` and app settings. The Teams AI Library v2 requires Node 20+. [learn.microsoft.com -- Node.js on App Service](https://learn.microsoft.com/en-us/azure/app-service/configure-language-nodejs) +6. Migrate environment variables from AWS Lambda environment / SSM to Azure App Settings. App Settings are injected as `process.env` variables at runtime, equivalent to Lambda environment variables. Use deployment slots for staging/production separation. [learn.microsoft.com -- App Settings](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-app-settings) +7. Configure health check endpoints for all Azure compute targets. App Service supports built-in health checks (Configuration > Health check path: `/api/health`). Container Apps use liveness and readiness probes. This replaces Lambda/ECS health monitoring. [learn.microsoft.com -- Health checks](https://learn.microsoft.com/en-us/azure/app-service/monitor-instances-health-check) +8. For streaming and WebSocket scenarios (e.g., AI streaming responses via `stream.emit()`), use App Service or Container Apps with WebSocket support enabled. Azure Functions Consumption plan does not support WebSockets. Enable WebSockets in App Service under Configuration > General Settings. [learn.microsoft.com -- WebSockets](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-general-settings) +9. Use deployment slots in App Service for zero-downtime deployments, replacing blue/green patterns built with Lambda aliases/versions or ECS rolling updates. Swap staging to production after validation. [learn.microsoft.com -- Deployment slots](https://learn.microsoft.com/en-us/azure/app-service/deploy-staging-slots) +10. For complex multi-container deployments (previously ECS task definitions with sidecars), use Azure Container Apps with multiple containers per revision, or Azure Kubernetes Service for full orchestration control. Container Apps supports scale-to-zero similar to Fargate Spot. [learn.microsoft.com -- Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview) +11. **Azure Functions Premium "Always Ready" instances eliminate cold starts.** Set `WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT` to cap horizontal scaling, and configure `alwaysReady` in the Premium plan to keep N instances warm. This is the serverless equivalent of ECS minimum task count. Required for Teams bots that must respond to invoke activities within 3 seconds. [learn.microsoft.com -- Functions Premium Always Ready](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan#always-ready-instances) +12. **Container Apps with Dapr sidecars replaces ECS multi-container patterns.** ECS task definitions with multiple containers (app + sidecar) map to Container Apps revisions with Dapr enabled. Dapr provides service-to-service invocation, state management, pub/sub, and secrets — replacing custom service mesh code. Service discovery uses Dapr app IDs instead of ECS service discovery or Cloud Map. [learn.microsoft.com -- Dapr on Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/dapr-overview) + +## patterns + +### AWS Lambda to Azure App Service deployment + +```shell +# Create resource group and App Service plan +az group create --name my-bot-rg --location eastus +az appservice plan create \ + --name my-bot-plan \ + --resource-group my-bot-rg \ + --sku B1 \ + --is-linux + +# Create the web app with Node.js 20 +az webapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --plan my-bot-plan \ + --runtime "NODE:20-lts" + +# Enable Always On and WebSockets +az webapp config set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --always-on true \ + --web-sockets-enabled true + +# Set application settings (replaces Lambda env vars) +az webapp config appsettings set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --settings \ + CLIENT_ID="your-client-id" \ + CLIENT_SECRET="your-client-secret" \ + TENANT_ID="your-tenant-id" \ + OPENAI_API_KEY="your-openai-key" \ + PORT="8080" \ + NODE_ENV="production" + +# Deploy from zip (build locally first: npm run build && zip -r dist.zip .) +az webapp deployment source config-zip \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --src ./dist.zip + +# Configure health check +az webapp config set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --generic-configurations '{"healthCheckPath": "/api/health"}' +``` + +### Azure Functions HTTP trigger for Teams bot endpoint + +```typescript +// src/functions/messages.ts +import { app as azFunc, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +// Initialize the Teams app once (reused across invocations) +const teamsApp = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +teamsApp.on("message", async ({ send, activity }) => { + await send(`You said: "${activity.text}"`); +}); + +// Azure Functions HTTP trigger bound to /api/messages +azFunc.http("messages", { + methods: ["POST"], + authLevel: "anonymous", + route: "api/messages", + handler: async (req: HttpRequest, context: InvocationContext): Promise => { + try { + const body = await req.json(); + // Forward the request to the Teams app for processing + // In practice, use the adapter pattern from @microsoft/teams.apps + // to bridge Azure Functions HTTP to the Teams app's Express handler + return { status: 200, jsonBody: { status: "ok" } }; + } catch (error) { + context.error("Error processing message:", error); + return { status: 500, jsonBody: { error: "Internal server error" } }; + } + }, +}); +``` + +### Container Apps deployment for ECS/Fargate migration + +```shell +# Create Container Apps environment (replaces ECS cluster) +az containerapp env create \ + --name my-bot-env \ + --resource-group my-bot-rg \ + --location eastus + +# Deploy container (replaces ECS task definition + service) +az containerapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --environment my-bot-env \ + --image myregistry.azurecr.io/my-teams-bot:latest \ + --target-port 3978 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --cpu 0.5 \ + --memory 1.0Gi \ + --env-vars \ + CLIENT_ID="your-client-id" \ + CLIENT_SECRET=secretref:client-secret \ + TENANT_ID="your-tenant-id" \ + NODE_ENV="production" + +# Configure scaling rule based on HTTP concurrent requests +az containerapp update \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --scale-rule-name http-rule \ + --scale-rule-type http \ + --scale-rule-http-concurrency 50 +``` + +### Container Apps with Dapr sidecar (ECS multi-container migration) + +```shell +# Create a Dapr-enabled Container App (replaces ECS task with sidecar containers) +az containerapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --environment my-bot-env \ + --image myregistry.azurecr.io/my-teams-bot:latest \ + --target-port 3978 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --enable-dapr \ + --dapr-app-id my-teams-bot \ + --dapr-app-port 3978 \ + --dapr-app-protocol http +``` + +**TypeScript: invoking another service via Dapr (replaces ECS service discovery):** + +```typescript +import { DaprClient, HttpMethod } from "@dapr/dapr"; + +const dapr = new DaprClient(); + +// Invoke another Container App by its Dapr app ID +// Replaces: http://service-name.local:3000/api/data (ECS service discovery) +async function callDataService(query: string) { + const response = await dapr.invoker.invoke( + "data-service", // Dapr app ID (replaces ECS service name) + `api/search?q=${query}`, // method/path + HttpMethod.GET + ); + return response; +} +``` + +## pitfalls + +- **Azure Functions Consumption cold starts**: Node.js cold starts on the Consumption plan can take 5-10 seconds. Teams invoke activities (card actions, dialogs) time out at 3 seconds. Either use the Premium plan with at least one pre-warmed instance, or use App Service with Always On enabled. +- **Forgetting Always On**: App Service without Always On unloads the app after ~20 minutes idle. The next incoming Teams message triggers a full restart, causing timeout errors. Always enable Always On for bot workloads. +- **Port mismatch**: Azure App Service expects the app to listen on `process.env.PORT` (defaults to `8080`), not the Teams default of `3978`. Set `PORT` in App Settings or update `app.start(process.env.PORT || 8080)` for App Service deployments. +- **Missing /api/messages route**: The Azure Bot registration messaging endpoint must point to `https://your-app.azurewebsites.net/api/messages`. If the Teams app listens on a different path, update the Bot registration accordingly. +- **Lambda-style single-invocation patterns**: AWS Lambda processes one request per invocation. Azure App Service and Container Apps are long-running processes. Remove any Lambda-specific initialization/teardown patterns (handler export patterns, context.callbackWaitsForEmptyEventLoop) and use the standard `app.start()` pattern. +- **Deployment slot swap without warming**: Swapping a cold staging slot to production causes the same cold-start problem. Use slot warm-up rules or send traffic to staging before swapping. +- **Container Apps scale-to-zero**: If min-replicas is 0, the first request after scale-down has a cold start. Set `--min-replicas 1` for production Teams bots to ensure instant responses. +- **Functions Premium Always Ready is not free-tier**: Always Ready instances incur charges even when idle. Budget for at least 1 always-ready instance per production function app. Without it, the Premium plan still has occasional cold starts during scale-out events. +- **Dapr sidecar port conflict**: Dapr's default HTTP port is 3500 and gRPC is 50001. Ensure your app does not bind to these ports. The `--dapr-app-port` flag tells Dapr which port YOUR app listens on — this must match your Express/Teams `app.start()` port. + +## references + +- [Azure App Service overview](https://learn.microsoft.com/en-us/azure/app-service/overview) +- [Azure Functions Node.js developer guide](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node) +- [Azure Container Apps overview](https://learn.microsoft.com/en-us/azure/container-apps/overview) +- [Azure Functions Premium plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan) +- [Deploy a bot to Azure](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-deploy-az-cli) +- [Azure App Service deployment slots](https://learn.microsoft.com/en-us/azure/app-service/deploy-staging-slots) +- [Configure Node.js apps for App Service](https://learn.microsoft.com/en-us/azure/app-service/configure-language-nodejs) +- [AWS to Azure services comparison](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services) + +## instructions + +This expert bridges compute infrastructure between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map compute services between clouds (Lambda ↔ Azure Functions, ECS ↔ Container Apps, EC2 ↔ App Service) +- Configure Azure App Service for a Teams bot with proper Always On, WebSocket, and Node.js runtime settings +- Set up Azure Functions as a Teams bot endpoint while avoiding cold-start pitfalls +- Deploy containerized bots to Azure Container Apps as a replacement for ECS/Fargate +- Bridge environment variables and deployment configurations between AWS and Azure +- Configure health checks, scaling rules, and deployment slots for production bot hosting + +For Azure → AWS (less common): reverse the mappings. App Service maps to EC2 or Elastic Beanstalk, Azure Functions maps to Lambda + API Gateway, Container Apps maps to ECS/Fargate. + +Pair with `infra-secrets-config-ts.md` for App Settings and environment variable configuration, and `../teams/dev.debug-test-ts.md` for local development setup. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging bot compute between AWS and Azure. Provide a bidirectional decision matrix mapping AWS Lambda+API Gateway ↔ Azure Functions, ECS/Fargate ↔ Container Apps, and EC2 ↔ App Service. Include Node/TS hosting patterns, ingress/routing, env var configuration, scaling differences, cold start mitigation for Teams 3-second response requirements, and bot endpoint considerations. Include deployment CLI examples for both directions." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/infra-observability-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-observability-ts.md new file mode 100644 index 000000000..7b1ee6eaa --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-observability-ts.md @@ -0,0 +1,256 @@ +# infra-observability-ts + +## purpose + +Bridges AWS and Azure observability for cross-platform bot monitoring. Covers CloudWatch to Azure Monitor/Application Insights/Log Analytics (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Application Insights → CloudWatch + X-Ray, Log Analytics (KQL) → CloudWatch Logs Insights, Azure Monitor Alerts → CloudWatch Alarms + SNS. + +## rules + +1. Map CloudWatch Logs to Application Insights and Log Analytics. Application Insights provides structured telemetry (requests, dependencies, exceptions, traces) while Log Analytics is the query engine (KQL) for exploring that data. Both replace CloudWatch Logs Insights. [learn.microsoft.com -- Application Insights overview](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) +2. Instrument Node.js Teams bots with the `applicationinsights` npm package. Call `setup()` and `start()` before any other imports to enable automatic dependency tracking, request correlation, and exception capture. This replaces AWS X-Ray SDK instrumentation. [learn.microsoft.com -- Node.js Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +3. Map CloudWatch Metrics to Azure Monitor Metrics. Custom metrics sent via `trackMetric()` in Application Insights appear in Azure Monitor Metrics Explorer, replacing CloudWatch custom metrics and `putMetricData` calls. [learn.microsoft.com -- Custom metrics](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +4. Map CloudWatch Alarms to Azure Monitor Alerts. Create alert rules on Application Insights metrics (response time, failure rate, exception count) or log-based alerts using KQL queries. This replaces CloudWatch Alarm + SNS notification patterns. [learn.microsoft.com -- Azure Monitor Alerts](https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-overview) +5. Map AWS X-Ray distributed tracing to Application Insights distributed tracing. Application Insights automatically correlates requests across services using operation IDs. The `applicationinsights` SDK propagates trace context headers (`traceparent`) automatically. [learn.microsoft.com -- Distributed tracing](https://learn.microsoft.com/en-us/azure/azure-monitor/app/distributed-trace-data) +6. Integrate with the Teams SDK `ConsoleLogger` by creating a custom logger implementation that forwards to Application Insights. Use `trackTrace()` for log messages, `trackException()` for errors, and `trackEvent()` for business events (bot installs, card actions). [learn.microsoft.com -- Application Insights API](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +7. Set the Application Insights connection string via the `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable in App Settings. Do not hardcode connection strings. App Service and Functions have built-in Application Insights integration that can be enabled without code changes for basic telemetry. [learn.microsoft.com -- Connection strings](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sdk-connection-string) +8. Use KQL queries in Log Analytics to diagnose bot issues, replacing CloudWatch Logs Insights queries. Query `requests`, `dependencies`, `exceptions`, and `traces` tables. Pin frequently used queries to Azure dashboards for team visibility. [learn.microsoft.com -- KQL overview](https://learn.microsoft.com/en-us/azure/data-explorer/kusto/query/) +9. Configure sampling to control telemetry volume and cost. Application Insights supports adaptive sampling (automatic) and fixed-rate sampling. For production bots with high message volume, set a sampling percentage to avoid excessive costs while retaining representative data. [learn.microsoft.com -- Sampling](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sampling-classic-api) +10. Build Azure dashboards for bot health monitoring, replacing CloudWatch Dashboards. Include panels for request rate, response time (P50/P95/P99), failure rate, active conversations, and AI model latency. Use Application Insights workbooks for detailed investigation views. [learn.microsoft.com -- Dashboards](https://learn.microsoft.com/en-us/azure/azure-monitor/app/overview-dashboard) + +## patterns + +### Application Insights setup for a Teams bot + +```typescript +// src/instrumentation.ts — MUST be imported before all other modules +import * as appInsights from "applicationinsights"; + +appInsights + .setup(process.env.APPLICATIONINSIGHTS_CONNECTION_STRING) + .setAutoCollectRequests(true) + .setAutoCollectPerformance(true) + .setAutoCollectExceptions(true) + .setAutoCollectDependencies(true) + .setAutoCollectConsole(true, true) // capture console.log and console.error + .setDistributedTracingMode(appInsights.DistributedTracingModes.AI_AND_W3C) + .setSendLiveMetrics(true) + .start(); + +export const telemetryClient = appInsights.defaultClient; +``` + +```typescript +// src/index.ts +import { telemetryClient } from "./instrumentation.js"; // import first! +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Track custom events for bot lifecycle +app.on("install.add", async ({ send, activity }) => { + telemetryClient.trackEvent({ + name: "BotInstalled", + properties: { + conversationType: activity.conversation.conversationType ?? "personal", + tenantId: activity.conversation.tenantId ?? "unknown", + }, + }); + await send("Hello! I am now installed."); +}); + +// Track AI prompt latency as a custom metric +app.on("message", async ({ send, activity }) => { + const start = Date.now(); + // ... process with AI prompt ... + const duration = Date.now() - start; + + telemetryClient.trackMetric({ + name: "AIPromptLatency", + value: duration, + properties: { conversationId: activity.conversation.id }, + }); +}); + +// Track unhandled errors +app.event("error", ({ error }) => { + telemetryClient.trackException({ exception: error as Error }); +}); + +app.start(process.env.PORT || 3978); +``` + +### KQL queries for bot diagnostics + +```text +// Request latency for the /api/messages endpoint (replaces CloudWatch Logs Insights) +requests +| where name == "POST /api/messages" +| where timestamp > ago(24h) +| summarize + avg(duration), + percentile(duration, 50), + percentile(duration, 95), + percentile(duration, 99), + count() + by bin(timestamp, 5m) +| render timechart + +// Failed requests with exception details +requests +| where success == false +| where timestamp > ago(1h) +| join kind=inner ( + exceptions + | where timestamp > ago(1h) + ) on operation_Id +| project timestamp, name, resultCode, duration, exceptionType = type, exceptionMessage = outerMessage +| order by timestamp desc +| take 50 + +// Bot install/uninstall events over time +customEvents +| where name in ("BotInstalled", "BotUninstalled") +| where timestamp > ago(7d) +| summarize count() by name, bin(timestamp, 1d) +| render columnchart + +// AI prompt latency distribution +customMetrics +| where name == "AIPromptLatency" +| where timestamp > ago(24h) +| summarize avg(value), percentile(value, 95), max(value) by bin(timestamp, 15m) +| render timechart + +// Dependency call failures (external APIs, databases) +dependencies +| where success == false +| where timestamp > ago(6h) +| summarize failureCount = count() by target, name, resultCode +| order by failureCount desc +``` + +### Custom logger that bridges ConsoleLogger to Application Insights + +```typescript +// src/logger.ts +import * as appInsights from "applicationinsights"; +import { ILogger } from "@microsoft/teams.common"; + +export class AppInsightsLogger implements ILogger { + private client: appInsights.TelemetryClient; + private name: string; + + constructor(name: string, client?: appInsights.TelemetryClient) { + this.name = name; + this.client = client ?? appInsights.defaultClient; + } + + error(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.error(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Error, + properties: { component: this.name }, + }); + } + + warn(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.warn(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Warning, + properties: { component: this.name }, + }); + } + + info(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.info(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Information, + properties: { component: this.name }, + }); + } + + debug(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.debug(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Verbose, + properties: { component: this.name }, + }); + } + + log(message: string, ...args: unknown[]): void { + this.info(message, ...args); + } + + child(name: string): ILogger { + return new AppInsightsLogger(`${this.name}/${name}`, this.client); + } + + private format(message: string, args: unknown[]): string { + return args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message; + } +} + +// Usage in src/index.ts: +// import { AppInsightsLogger } from "./logger.js"; +// const app = new App({ +// logger: new AppInsightsLogger("my-bot"), +// ... +// }); +``` + +## pitfalls + +- **Late instrumentation import**: The `applicationinsights` setup must run before importing any other modules (especially `http`/`https`). If imported after, automatic dependency tracking and request correlation will not work. Always import the instrumentation module first in your entry point. +- **Missing connection string**: If `APPLICATIONINSIGHTS_CONNECTION_STRING` is not set, the SDK initializes silently in no-op mode. Telemetry is lost without any error. Always verify the connection string is configured in App Settings. +- **CloudWatch Logs Insights queries not portable**: CloudWatch Logs Insights query syntax is completely different from KQL. All existing dashboard queries must be manually rewritten in KQL. The table structures also differ (e.g., `@timestamp` becomes `timestamp`, `@message` becomes `message`). +- **Cost surprise from high-volume bots**: Application Insights charges per GB of ingested telemetry. A high-traffic bot logging every message can generate significant costs. Configure sampling early and exclude verbose trace levels in production. +- **Console.log not structured**: Raw `console.log` statements captured by Application Insights appear as unstructured trace messages. Use `trackEvent()`, `trackMetric()`, and `trackTrace()` with properties for queryable, structured telemetry. +- **X-Ray annotations not migrated**: AWS X-Ray annotations and metadata have no automatic migration path to Application Insights custom properties. Manually map important annotations to `trackTrace()` or `trackEvent()` property bags. +- **Forgetting to flush on shutdown**: Application Insights batches telemetry before sending. If the process exits abruptly (e.g., container restart), buffered telemetry is lost. Call `telemetryClient.flush()` in a graceful shutdown handler. + +## references + +- [Application Insights overview](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) +- [Application Insights for Node.js](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +- [Application Insights API reference](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +- [KQL quick reference](https://learn.microsoft.com/en-us/azure/data-explorer/kusto/query/kql-quick-reference) +- [Azure Monitor Alerts](https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-overview) +- [Application Insights sampling](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sampling-classic-api) +- [Distributed tracing in Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/distributed-trace-data) +- [AWS to Azure services comparison -- Management and monitoring](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#management-and-monitoring) + +## instructions + +This expert bridges observability between AWS and Azure for cross-platform bot monitoring. Use it when adding cross-platform support in either direction and you need to: + +- Map monitoring services between clouds (CloudWatch ↔ Azure Monitor, X-Ray ↔ Application Insights, CloudWatch Alarms ↔ Azure Alerts) +- Instrument a Node.js Teams bot with the `applicationinsights` npm package +- Write KQL queries for bot diagnostics (latency, errors, usage patterns) +- Build Azure dashboards for bot health monitoring +- Bridge the Teams SDK `ConsoleLogger` to Application Insights telemetry + +For Azure → AWS (less common): reverse the mappings. Application Insights maps to CloudWatch + X-Ray, KQL maps to CloudWatch Logs Insights, Azure Alerts map to CloudWatch Alarms + SNS. + +Pair with `../teams/dev.debug-test-ts.md` for Teams SDK ConsoleLogger integration, and `infra-compute-ts.md` for Application Insights instrumentation on the target compute platform. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging observability between AWS CloudWatch and Azure Monitor/Application Insights for cross-platform bots. Cover structured logging with the applicationinsights npm package, distributed tracing (X-Ray ↔ Application Insights), KQL ↔ CloudWatch Logs Insights query mapping, custom metrics, alert rules, dashboard setup, and cost management with sampling bidirectionally. Include instrumentation code examples and diagnostic queries." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/infra-secrets-config-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-secrets-config-ts.md new file mode 100644 index 000000000..fc7ff8768 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-secrets-config-ts.md @@ -0,0 +1,193 @@ +# infra-secrets-config-ts + +## purpose + +Bridges AWS and Azure secrets/configuration management for cross-platform bot deployments. Covers Secrets Manager/SSM to Key Vault/App Configuration (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Key Vault → Secrets Manager, App Configuration → SSM Parameter Store, managed identity → IAM roles. + +## rules + +1. Map AWS Secrets Manager to Azure Key Vault for storing sensitive credentials (CLIENT_SECRET, OPENAI_API_KEY, database passwords). Key Vault provides versioning, soft-delete, access policies, and audit logging, similar to Secrets Manager. Use `@azure/keyvault-secrets` for programmatic access. [learn.microsoft.com -- Key Vault overview](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +2. Map AWS SSM Parameter Store to Azure App Configuration for non-secret configuration values (feature flags, endpoint URLs, tuning parameters). App Configuration supports key-value pairs, labels for environments, and feature management. Use `@azure/app-configuration` for programmatic access. [learn.microsoft.com -- App Configuration overview](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) +3. Use managed identity (system-assigned or user-assigned) to access Key Vault from Azure compute, eliminating the need for Key Vault credentials in code. This replaces IAM role-based access patterns used with AWS Secrets Manager. Configure with `@azure/identity` DefaultAzureCredential. [learn.microsoft.com -- Managed identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) +4. For App Service deployments, use Key Vault references in App Settings instead of direct secret values. The syntax `@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/MySecret/)` resolves secrets at runtime without application code changes. This is the simplest migration path from `.env` files. [learn.microsoft.com -- Key Vault references](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) +5. Migrate all Teams bot environment variables from `.env` files to Azure App Settings for production. Required variables: `CLIENT_ID`, `CLIENT_SECRET`, `TENANT_ID`. Common additions: `OPENAI_API_KEY` or `AZURE_OPENAI_*`, `APPLICATIONINSIGHTS_CONNECTION_STRING`, `PORT`. Keep `.env` for local development only. [learn.microsoft.com -- App Settings](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-app-settings) +6. Never commit secrets to source control. Add `.env` to `.gitignore`. Use `.env.example` or `.env.template` with placeholder values to document required variables. This applies equally to AWS and Azure workflows. [OWASP -- Secrets in source code](https://owasp.org/www-community/vulnerabilities/Use_of_hard-coded_password) +7. Configure secret rotation for `CLIENT_SECRET` using Key Vault rotation policies or Azure AD app credential rotation. AWS Secrets Manager automatic rotation maps to Key Vault auto-rotation with Event Grid notifications. Plan for multi-credential overlap during rotation windows. [learn.microsoft.com -- Key Vault rotation](https://learn.microsoft.com/en-us/azure/key-vault/secrets/tutorial-rotation) +8. Use the Teams SDK `managedIdentityClientId` option for zero-secret bot authentication in production. Set to `"system"` for system-assigned managed identity or the client ID string for user-assigned identity. This eliminates the need for `CLIENT_SECRET` in production entirely. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. Apply least-privilege access policies to Key Vault. Grant only "Get" and "List" secret permissions to the bot's managed identity. Do not grant "Set", "Delete", or management permissions to runtime identities. Use separate access policies for deployment pipelines vs. runtime. [learn.microsoft.com -- Key Vault access policy](https://learn.microsoft.com/en-us/azure/key-vault/general/assign-access-policy) +10. For local development, use `DefaultAzureCredential` from `@azure/identity` which chains multiple credential sources: environment variables, managed identity, Azure CLI login, and VS Code credentials. This provides a unified auth pattern that works locally and in production without code changes. [learn.microsoft.com -- DefaultAzureCredential](https://learn.microsoft.com/en-us/azure/developer/javascript/sdk/authentication/credential-chains#use-defaultazurecredential-for-flexibility) + +## patterns + +### Accessing Key Vault secrets from a Teams bot + +```typescript +// src/config.ts +import { DefaultAzureCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; + +interface BotConfig { + clientId: string; + clientSecret: string; + tenantId: string; + openaiApiKey: string; +} + +export async function loadConfig(): Promise { + const vaultUrl = process.env.KEY_VAULT_URL; + + // In production: uses managed identity automatically + // Locally: uses Azure CLI credentials or env vars + if (vaultUrl) { + const credential = new DefaultAzureCredential(); + const client = new SecretClient(vaultUrl, credential); + + const [clientId, clientSecret, tenantId, openaiKey] = await Promise.all([ + client.getSecret("bot-client-id"), + client.getSecret("bot-client-secret"), + client.getSecret("bot-tenant-id"), + client.getSecret("openai-api-key"), + ]); + + return { + clientId: clientId.value!, + clientSecret: clientSecret.value!, + tenantId: tenantId.value!, + openaiApiKey: openaiKey.value!, + }; + } + + // Fallback to environment variables for local development + return { + clientId: process.env.CLIENT_ID ?? "", + clientSecret: process.env.CLIENT_SECRET ?? "", + tenantId: process.env.TENANT_ID ?? "", + openaiApiKey: process.env.OPENAI_API_KEY ?? "", + }; +} + +// src/index.ts +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { loadConfig } from "./config.js"; + +const config = await loadConfig(); + +const app = new App({ + clientId: config.clientId, + clientSecret: config.clientSecret, + tenantId: config.tenantId, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message", async ({ send }) => { + await send("Bot is running with Key Vault secrets!"); +}); + +app.start(process.env.PORT || 3978); +``` + +### App Service Key Vault references (zero-code secret injection) + +```shell +# Create Key Vault +az keyvault create \ + --name my-bot-vault \ + --resource-group my-bot-rg \ + --location eastus + +# Store secrets in Key Vault +az keyvault secret set --vault-name my-bot-vault --name "BotClientId" --value "your-client-id" +az keyvault secret set --vault-name my-bot-vault --name "BotClientSecret" --value "your-client-secret" +az keyvault secret set --vault-name my-bot-vault --name "BotTenantId" --value "your-tenant-id" +az keyvault secret set --vault-name my-bot-vault --name "OpenAiApiKey" --value "your-openai-key" + +# Enable system-assigned managed identity on App Service +az webapp identity assign \ + --name my-teams-bot \ + --resource-group my-bot-rg + +# Grant the managed identity access to Key Vault secrets +PRINCIPAL_ID=$(az webapp identity show --name my-teams-bot --resource-group my-bot-rg --query principalId -o tsv) +az keyvault set-policy \ + --name my-bot-vault \ + --object-id "$PRINCIPAL_ID" \ + --secret-permissions get list + +# Set App Settings with Key Vault references (no secrets in App Settings!) +az webapp config appsettings set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --settings \ + CLIENT_ID="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotClientId/)" \ + CLIENT_SECRET="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotClientSecret/)" \ + TENANT_ID="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotTenantId/)" \ + OPENAI_API_KEY="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/OpenAiApiKey/)" +``` + +### Managed identity bot configuration (zero-secret production) + +```typescript +// src/index.ts — Production: no CLIENT_SECRET needed at all +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + tenantId: process.env.TENANT_ID, + // Use managed identity instead of CLIENT_SECRET + // "system" for system-assigned, or a specific client ID for user-assigned + managedIdentityClientId: process.env.MANAGED_IDENTITY_CLIENT_ID ?? "system", + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message", async ({ send }) => { + await send("Running with managed identity - no secrets in config!"); +}); + +app.start(process.env.PORT || 3978); +``` + +## pitfalls + +- **Key Vault references showing raw `@Microsoft.KeyVault(...)` string**: If the App Service cannot resolve Key Vault references, the raw reference string is used as the value instead of the secret. This happens when managed identity lacks "Get" permission on the vault or when the secret URI is malformed. Check the App Service "Configuration" blade for a green checkmark next to each reference. +- **DefaultAzureCredential slow locally**: `DefaultAzureCredential` tries multiple credential sources in sequence. If early sources timeout (e.g., managed identity endpoint on a dev machine), it can take 10+ seconds. For local development, use `AzureCliCredential` directly or set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` environment variables. +- **Forgetting to restart after App Settings change**: Azure App Service caches environment variables at startup. After updating App Settings or Key Vault references, restart the App Service to pick up the new values. +- **SSM Parameter Store hierarchical paths not mapped**: AWS SSM supports hierarchical parameter paths (`/myapp/prod/db-password`). Azure App Configuration uses flat key-value pairs with optional labels. Flatten the hierarchy or use labels (`key=db-password, label=prod`) during migration. +- **Secret rotation breaking the bot**: When rotating `CLIENT_SECRET` in Azure AD, both the old and new credentials must be valid simultaneously during the transition. Add the new credential first, update Key Vault, then remove the old credential after confirming the bot works. +- **Mixing .env and App Settings**: In production, App Settings override `.env` values. If both are present with different values, the App Settings value wins. Remove `.env` from deployment packages to avoid confusion. +- **Key Vault soft-delete blocking recreation**: Key Vault has soft-delete enabled by default. If you delete and recreate a vault with the same name, the operation fails. Purge the soft-deleted vault first or use a different name. + +## references + +- [Azure Key Vault overview](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +- [Azure App Configuration overview](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) +- [Key Vault references for App Service](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) +- [Managed identities overview](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) +- [@azure/identity -- DefaultAzureCredential](https://learn.microsoft.com/en-us/azure/developer/javascript/sdk/authentication/credential-chains) +- [@azure/keyvault-secrets npm](https://www.npmjs.com/package/@azure/keyvault-secrets) +- [Key Vault secret rotation tutorial](https://learn.microsoft.com/en-us/azure/key-vault/secrets/tutorial-rotation) +- [AWS to Azure services comparison -- Security](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#security-identity-and-access) + +## instructions + +This expert bridges secrets and configuration management between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map secrets services between clouds (Secrets Manager ↔ Key Vault, SSM ↔ App Configuration) +- Set up Key Vault references in App Service App Settings for zero-code secret injection +- Configure managed identity for passwordless access to Key Vault and other Azure services +- Bridge `.env` files to production-ready App Settings on either cloud +- Implement the `managedIdentityClientId` option in the Teams SDK for zero-secret bot authentication +- Plan secret rotation for CLIENT_SECRET and other credentials + +For Azure → AWS (less common): reverse the mappings. Key Vault maps to Secrets Manager, App Configuration maps to SSM Parameter Store, managed identity maps to IAM roles. + +Pair with `../security/secrets-ts.md` for general secrets management best practices, and `../teams/runtime.app-init-ts.md` for the Teams bot credentials that need to be stored. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging secrets/config between AWS and Azure for cross-platform bots. Cover Secrets Manager ↔ Key Vault mapping, SSM ↔ App Configuration, Key Vault references in App Service, managed identity ↔ IAM roles, @azure/keyvault-secrets and @azure/identity SDK usage, .env to App Settings migration, and secret rotation patterns bidirectionally. Include code examples and CLI commands." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/infra-storage-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-storage-ts.md new file mode 100644 index 000000000..256bd7e05 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/infra-storage-ts.md @@ -0,0 +1,248 @@ +# infra-storage-ts + +## purpose + +Bridges AWS and Azure data storage for cross-platform bot state and application data. Covers S3/DynamoDB/RDS to Azure Blob Storage/Cosmos DB/Azure SQL (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Blob Storage → S3, Cosmos DB → DynamoDB, Azure SQL → RDS. + +## rules + +1. Map AWS S3 to Azure Blob Storage for file and object storage. Both provide tiered storage (Hot/Cool/Archive maps to S3 Standard/IA/Glacier), versioning, and lifecycle policies. Use `@azure/storage-blob` for programmatic access. Container names in Blob Storage are equivalent to S3 buckets. [learn.microsoft.com -- Blob Storage overview](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) +2. Map AWS DynamoDB to Azure Cosmos DB for NoSQL key-value and document storage. Cosmos DB offers multiple APIs: Core SQL (recommended for new development), Table API (closest DynamoDB migration path), and MongoDB API. Choose based on query complexity and migration effort. [learn.microsoft.com -- Cosmos DB overview](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +3. Map AWS RDS (MySQL/PostgreSQL/SQL Server) to the equivalent Azure managed database: RDS MySQL maps to Azure Database for MySQL, RDS PostgreSQL maps to Azure Database for PostgreSQL, RDS SQL Server maps to Azure SQL Database. Schema and data can be migrated with Azure Database Migration Service. [learn.microsoft.com -- Azure SQL overview](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) +4. Implement the Teams SDK `IStorage` interface for bot state management with Cosmos DB. The `IStorage` interface requires `get(key)`, `set(key, value)`, and `delete(key)` methods. This replaces any custom DynamoDB state store used by a Slack Bolt bot. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +5. For simple bot state (conversation history, user preferences), use Cosmos DB Core SQL API with a single container partitioned by the state key. This provides single-digit millisecond reads, automatic indexing, and serverless pricing for low-traffic bots. [learn.microsoft.com -- Cosmos DB serverless](https://learn.microsoft.com/en-us/azure/cosmos-db/serverless) +6. Use managed identity or connection strings stored in Key Vault for database access. Never hardcode connection strings in source code. For Cosmos DB, use `@azure/cosmos` with `DefaultAzureCredential` for managed identity access, or store the connection string in Key Vault. [learn.microsoft.com -- Cosmos DB RBAC](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +7. For DynamoDB to Cosmos DB Table API migration, use the Azure Cosmos DB Data Migration Tool or custom scripts. Table API preserves the key-value access pattern (PartitionKey + RowKey), making it the lowest-effort migration path. However, Core SQL API offers richer querying capabilities for future needs. [learn.microsoft.com -- Cosmos DB Table API](https://learn.microsoft.com/en-us/azure/cosmos-db/table/introduction) +8. Configure Cosmos DB request units (RUs) appropriately. DynamoDB uses read/write capacity units (RCUs/WCUs); Cosmos DB uses RUs. A simple bot state read costs approximately 1 RU. Start with serverless mode (pay-per-request) for development and low traffic, switch to provisioned throughput for predictable workloads. [learn.microsoft.com -- Request units](https://learn.microsoft.com/en-us/azure/cosmos-db/request-units) +9. Plan data migration strategy: for S3 to Blob Storage, use AzCopy or Azure Data Factory for bulk migration. For DynamoDB to Cosmos DB, export to JSON from DynamoDB and import with the Cosmos DB Data Migration Tool. For RDS, use Azure Database Migration Service for online migration with minimal downtime. [learn.microsoft.com -- AzCopy](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-v10) +10. Implement retry logic and handle throttling for Cosmos DB operations. Unlike DynamoDB which returns `ProvisionedThroughputExceededException`, Cosmos DB returns HTTP 429 with a `x-ms-retry-after-ms` header. The `@azure/cosmos` SDK has built-in retry logic, but configure `maxRetryCount` and `retryAfterInMs` for your workload. [learn.microsoft.com -- Cosmos DB best practices](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/best-practice-dotnet) + +## patterns + +### IStorage implementation with Cosmos DB for bot state + +```typescript +// src/storage/cosmos-storage.ts +import { CosmosClient, Container, Database } from "@azure/cosmos"; +import { IStorage } from "@microsoft/teams.common"; + +export class CosmosDbStorage implements IStorage { + private container: Container; + private initialized = false; + + constructor( + private cosmosClient: CosmosClient, + private databaseId: string, + private containerId: string, + ) { + this.container = this.cosmosClient + .database(this.databaseId) + .container(this.containerId); + } + + async initialize(): Promise { + if (this.initialized) return; + + // Create database and container if they don't exist + const { database } = await this.cosmosClient.databases.createIfNotExists({ + id: this.databaseId, + }); + await database.containers.createIfNotExists({ + id: this.containerId, + partitionKey: { paths: ["/id"] }, + }); + + this.container = this.cosmosClient + .database(this.databaseId) + .container(this.containerId); + this.initialized = true; + } + + async get(key: string): Promise { + await this.initialize(); + try { + const { resource } = await this.container.item(key, key).read(); + if (!resource) return undefined; + // Strip Cosmos DB metadata before returning + const { id, _rid, _self, _etag, _attachments, _ts, ...data } = resource as Record; + return data as T; + } catch (error: unknown) { + if ((error as { code: number }).code === 404) return undefined; + throw error; + } + } + + async set(key: string, value: T): Promise { + await this.initialize(); + await this.container.items.upsert({ id: key, ...value as object }); + } + + async delete(key: string): Promise { + await this.initialize(); + try { + await this.container.item(key, key).delete(); + } catch (error: unknown) { + if ((error as { code: number }).code !== 404) throw error; + } + } +} +``` + +```typescript +// src/index.ts — Using CosmosDbStorage with the Teams app +import { App } from "@microsoft/teams.apps"; +import { CosmosClient } from "@azure/cosmos"; +import { CosmosDbStorage } from "./storage/cosmos-storage.js"; + +const cosmosClient = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!); +const storage = new CosmosDbStorage(cosmosClient, "teams-bot", "state"); + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + storage, // Cosmos DB backs all bot state +}); + +app.on("message", async ({ send, activity }) => { + // State is now persisted to Cosmos DB via the IStorage interface + await send(`Echo: ${activity.text}`); +}); + +app.start(process.env.PORT || 3978); +``` + +### S3 to Azure Blob Storage migration and access + +```typescript +// src/storage/blob-client.ts +import { BlobServiceClient, ContainerClient } from "@azure/storage-blob"; +import { DefaultAzureCredential } from "@azure/identity"; + +// Using managed identity (production) +const blobServiceClient = new BlobServiceClient( + `https://${process.env.STORAGE_ACCOUNT_NAME}.blob.core.windows.net`, + new DefaultAzureCredential(), +); + +// Or using connection string (development) +// const blobServiceClient = BlobServiceClient.fromConnectionString( +// process.env.AZURE_STORAGE_CONNECTION_STRING!, +// ); + +export async function uploadFile( + containerName: string, + blobName: string, + content: Buffer, +): Promise { + const containerClient = blobServiceClient.getContainerClient(containerName); + await containerClient.createIfNotExists(); + + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.upload(content, content.length); + return blockBlobClient.url; +} + +export async function downloadFile( + containerName: string, + blobName: string, +): Promise { + const containerClient = blobServiceClient.getContainerClient(containerName); + const blobClient = containerClient.getBlobClient(blobName); + const response = await blobClient.download(); + + const chunks: Buffer[] = []; + for await (const chunk of response.readableStreamBody!) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +// Migration command: bulk copy from S3 to Blob Storage +// azcopy copy "https://s3.amazonaws.com/my-bucket" \ +// "https://mystorageaccount.blob.core.windows.net/my-container?SAS_TOKEN" \ +// --recursive +``` + +### Cosmos DB with managed identity (replacing DynamoDB IAM role access) + +```typescript +// src/storage/cosmos-managed.ts +import { CosmosClient } from "@azure/cosmos"; +import { DefaultAzureCredential } from "@azure/identity"; + +// Managed identity access — no connection string needed +// Requires Cosmos DB RBAC role assignment: +// az cosmosdb sql role assignment create \ +// --account-name my-cosmos-db \ +// --resource-group my-bot-rg \ +// --scope "/" \ +// --principal-id \ +// --role-definition-id 00000000-0000-0000-0000-000000000002 # Built-in Data Contributor + +const credential = new DefaultAzureCredential(); + +const cosmosClient = new CosmosClient({ + endpoint: process.env.COSMOS_ENDPOINT!, // https://my-cosmos-db.documents.azure.com:443/ + aadCredentials: credential, +}); + +// Usage is identical to connection-string-based access +const database = cosmosClient.database("teams-bot"); +const container = database.container("state"); + +// Read an item +const { resource } = await container.item("user-123", "user-123").read(); + +// Upsert an item +await container.items.upsert({ + id: "user-123", + messages: [], + preferences: { theme: "dark" }, +}); +``` + +## pitfalls + +- **DynamoDB to Cosmos DB partition key mismatch**: DynamoDB uses a composite key (partition key + sort key). Cosmos DB Core SQL API uses a single partition key with a separate `id` field. Plan the key mapping carefully. If using Table API, PartitionKey + RowKey maps more directly. +- **Cosmos DB RU starvation**: Unlike DynamoDB auto-scaling which adjusts capacity based on traffic, Cosmos DB provisioned throughput has a fixed RU limit. Exceeding it causes 429 errors. Start with serverless mode or configure auto-scale (400-4000 RU/s) to handle traffic bursts. +- **Connection string in source code**: Cosmos DB connection strings contain the master key with full read/write access. Never hardcode them. Use managed identity with RBAC for production, or store connection strings in Key Vault. +- **Forgetting to create the database/container**: Unlike DynamoDB which creates tables on demand (with `CreateTable`), Cosmos DB requires explicit database and container creation. Use `createIfNotExists()` in the storage implementation or create resources via infrastructure-as-code. +- **Blob Storage access tier costs**: S3 to Blob Storage migration may change cost profiles. Blobs default to Hot tier; if the data is rarely accessed (like archived conversation logs), set to Cool or Archive tier to reduce costs. +- **Cosmos DB item size limit**: Cosmos DB items are limited to 2 MB. DynamoDB items are limited to 400 KB. While the Cosmos limit is higher, storing large conversation histories in a single item can approach this limit. Consider splitting long histories across multiple items. +- **Missing index policy tuning**: Cosmos DB indexes all properties by default (unlike DynamoDB where you must explicitly create secondary indexes). This is convenient but increases RU cost for writes. Exclude large text fields from indexing if they are never queried. +- **AzCopy SAS token expiration**: When using AzCopy for S3 to Blob migration, SAS tokens have expiration times. For large migrations that take hours, set a sufficiently long expiration or use managed identity with AzCopy. + +## references + +- [Azure Blob Storage overview](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) +- [Azure Cosmos DB overview](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +- [Azure SQL Database overview](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) +- [Cosmos DB Table API](https://learn.microsoft.com/en-us/azure/cosmos-db/table/introduction) +- [Cosmos DB RBAC with managed identity](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +- [@azure/cosmos npm](https://www.npmjs.com/package/@azure/cosmos) +- [@azure/storage-blob npm](https://www.npmjs.com/package/@azure/storage-blob) +- [AzCopy tool](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-v10) +- [AWS to Azure services comparison -- Storage](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#storage) + +## instructions + +This expert bridges data storage between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map storage services between clouds (S3 ↔ Blob Storage, DynamoDB ↔ Cosmos DB, RDS ↔ Azure SQL) +- Implement the Teams SDK `IStorage` interface backed by Cosmos DB for persistent bot state +- Choose between Cosmos DB Core SQL API and Table API for DynamoDB migration +- Set up managed identity access for Cosmos DB and Blob Storage (replacing IAM roles) +- Plan bulk data migration with AzCopy, Data Migration Tool, or Azure Data Factory + +For Azure → AWS (less common): reverse the mappings. Blob Storage maps to S3, Cosmos DB maps to DynamoDB, Azure SQL maps to RDS. + +Pair with `../teams/state.storage-patterns-ts.md` for implementing the Teams SDK IStorage interface with Cosmos DB, and `infra-secrets-config-ts.md` for securing connection strings. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging bot storage between AWS and Azure. Map S3 ↔ Azure Blob Storage, DynamoDB ↔ Cosmos DB (Core SQL vs Table API), and RDS ↔ Azure SQL/PostgreSQL bidirectionally. Include implementing the Teams SDK IStorage interface with Cosmos DB, managed identity access patterns, data migration strategies with AzCopy and Data Migration Tool, partition key mapping, and Node.js client code examples." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/interactive-responses-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/interactive-responses-ts.md new file mode 100644 index 000000000..a8f49d4d2 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/interactive-responses-ts.md @@ -0,0 +1,383 @@ +# interactive-responses-ts + +## purpose + +Bridges Slack interactive response patterns (respond, replace_original, ephemeral) and Teams card/message update patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `respond({ replace_original: true })` → Teams invoke response with card.** In Slack, `respond()` with `replace_original` replaces the message that triggered the interaction. In Teams, return a new Adaptive Card from the `card.action` handler's return value — the Bot Framework replaces the card inline. The handler must return `{ status: 200, body: { ... } }` with the replacement card. [learn.microsoft.com -- Universal Actions](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview) +2. **Slack `respond({ delete_original: true })` → Teams `deleteActivity(activityId)`.** Slack's delete-original flag removes the message. In Teams, call `deleteActivity(activityId)` on the turn context. You must store the original activity ID (from the `send()` return value or `activity.replyToId`) to delete it later. [learn.microsoft.com -- Delete activity](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-delete-activity) +3. **Slack `chat.update(channel, ts, ...)` → Teams `updateActivity(activityId, activity)`.** Both platforms support editing a bot's own message after sending. The key difference: Slack identifies messages by `channel + ts`, Teams uses `activityId` (returned from `send()`). Store the activity ID at send time. [learn.microsoft.com -- Update activity](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-update-activity) +4. **Slack `chat.postEphemeral()` has NO Teams equivalent.** Ephemeral messages visible only to one user do not exist in Teams. Redesign strategies: (a) send a message in the user's 1:1 bot chat, (b) use `Action.Execute` with `refresh.userIds` to show per-user card content, (c) simply send a visible message if privacy is not critical, (d) use a task module/dialog for private interaction. [learn.microsoft.com -- Conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics) +5. **Deferred response pattern: send "processing..." card, update later.** Slack's `response_url` allows 5 follow-up messages within 30 minutes. Teams has no `response_url` concept. Instead: (a) return a "Processing..." card from the invoke handler immediately, (b) store the conversation reference and activity ID, (c) use proactive messaging to update the card when processing completes. No expiry limit on updates. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +6. **Slack `response_url` (30-min, 5 follow-ups) → `send()` / `updateActivity()` with no expiry.** Slack's response_url is a webhook with time and count limits. Teams' `send()` and `updateActivity()` work indefinitely as long as you have a valid conversation reference. This is actually more flexible — but requires you to store conversation references yourself. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +7. **`Action.Execute` with `refresh.userIds` enables per-user card views.** Slack broadcasts the same message to everyone; only the interacting user sees ephemeral responses. Teams' `Action.Execute` with `refresh` can show different card content to different users — up to 60 user IDs per card. When specified users view the card, Teams automatically invokes the bot to get their personalized version. [learn.microsoft.com -- User-specific views](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/user-specific-views) +8. **Store activity IDs at send time.** Every `send()` in Teams returns an activity ID (or resource response). Store this ID if you need to update or delete the message later. Slack uses `channel + ts`; Teams uses a single opaque `activityId` string. Failing to store the ID means you cannot update the message. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. **Slack `respond({ response_type: 'in_channel' })` → `send()`.** Slack's `in_channel` response type makes an ephemeral-by-default response visible to everyone. In Teams, all bot messages are visible by default — simply call `send()`. There is no visibility toggle. [learn.microsoft.com -- Bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages) +10. **Card action handler must return within 3 seconds.** Teams invoke activities (including `Action.Execute` and `Action.Submit`) require a synchronous response within ~3 seconds. If processing takes longer, return a "processing" card immediately and update asynchronously via proactive messaging. Slack's `response_url` had a 30-minute window; Teams' invoke has a 3-second window. [learn.microsoft.com -- Invoke activities](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `updateActivity` to `respond({ replace_original: true })`, and card refresh (`Action.Execute` with `refresh.userIds`) to ephemeral messages via `chat.postEphemeral`. `deleteActivity` maps to `chat.delete(channel, ts)`. The 3-second invoke deadline has no Slack equivalent -- Slack's `response_url` gives 30 minutes, which is more lenient. + +## patterns + +### Card replacement flow (replace_original → invoke response) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Send initial message with a button +app.command("/approve", async ({ ack, respond }) => { + await ack(); + await respond({ + response_type: "in_channel", + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "Request #123 needs approval" }, + accessory: { + type: "button", + text: { type: "plain_text", text: "Approve" }, + action_id: "approve_request", + value: "123", + }, + }, + ], + }); +}); + +// Replace the original message when button is clicked +app.action("approve_request", async ({ ack, respond, body }) => { + await ack(); + await respond({ + replace_original: true, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Request #123 — *Approved* by <@${body.user.id}>`, + }, + }, + ], + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Send initial approval card +app.message(/^\/?approve$/i, async ({ send }) => { + const response = await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Request #123 needs approval", weight: "Bolder" }, + ], + actions: [{ + type: "Action.Execute", + title: "Approve", + verb: "approveRequest", + data: { requestId: "123" }, + }], + }, + }], + }); + // Store response.id if you need to update/delete later via proactive messaging +}); + +// Handle Action.Execute — return replacement card (replaces replace_original) +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "approveRequest") { + const approver = activity.from?.name ?? "Someone"; + // Returning a card from the handler replaces the original card inline + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: `Request #${data.requestId} — **Approved** by ${approver}`, + wrap: true, + }, + ], + // No actions = card becomes read-only after approval + }, + }; + } +}); + +app.start(3978); +``` + +### Deferred response with processing indicator + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.action("run_report", async ({ ack, respond }) => { + await ack(); + + // Immediate feedback + await respond({ replace_original: true, text: "Generating report..." }); + + // Long-running task — uses response_url (valid for 30 min, 5 follow-ups) + const report = await generateReport(); // takes 15 seconds + await respond({ + replace_original: true, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `Report ready: ${report.url}` }, + }, + ], + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store conversation references for proactive updates +const conversationRefs = new Map(); + +app.on("card.action" as any, async ({ activity, send }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "runReport") { + // Store conversation reference for later proactive update + const convRef = { + conversationId: activity.conversation?.id, + serviceUrl: (activity as any).serviceUrl, + }; + + // Return "processing" card immediately (must respond within 3 seconds) + const processingCard = { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Generating report...", isSubtle: true }, + { + type: "TextBlock", + text: "This may take a moment. The card will update when ready.", + wrap: true, + size: "Small", + }, + ], + }, + }; + + // Kick off async work — update the card when done + // No 30-minute expiry like Slack's response_url + setImmediate(async () => { + try { + const report = await generateReport(); + // Proactive message to update the card + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Report Ready", weight: "Bolder" }, + { type: "TextBlock", text: `[Download Report](${report.url})`, wrap: true }, + ], + }, + }], + }); + } catch (err) { + await send("Report generation failed. Please try again."); + } + }); + + return processingCard; + } +}); + +async function generateReport() { + // Simulate long-running work + await new Promise((r) => setTimeout(r, 15000)); + return { url: "https://example.com/report.pdf" }; +} + +app.start(3978); +``` + +### Ephemeral workaround: `refresh.userIds` (R1) + +Use `Action.Execute` with `refresh.userIds` to show personalized card content to specific users — the closest Teams equivalent to Slack's `chat.postEphemeral()`. + +```typescript +// Send a card where only the acting user sees personalized content +async function sendWithEphemeralView( + send: (msg: any) => Promise, + actingUserId: string, + publicText: string, + privateData: Record +): Promise { + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.4", + refresh: { + action: { + type: "Action.Execute", + verb: "personalView", + data: privateData, + }, + userIds: [actingUserId], // max 60 IDs + }, + body: [ + { type: "TextBlock", text: publicText }, // everyone sees this + ], + }, + }], + }); +} + +// When the specified user views the card, Teams invokes the bot: +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "personalView") { + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { type: "TextBlock", text: "This content is only visible to you.", weight: "Bolder" }, + { type: "FactSet", facts: [ + { title: "Request ID", value: data.requestId }, + { title: "Status", value: "Pending your review" }, + ]}, + ], + }, + }; + } +}); +``` + +**Key constraints:** Max 60 user IDs per card. Requires `Action.Execute` (not `Action.Submit`). Manifest version must be ≥1.12. + +**Reverse (Teams → Slack):** Map card refresh to `chat.postEphemeral(channel, user, { blocks })`. + +### Card version checking (Y11) + +Inject a `_version` counter into `Action.Submit.data` to prevent race conditions — the Teams equivalent of Slack's `view_hash` parameter. + +```typescript +// Track version per card instance +const cardVersions = new Map(); + +function buildVersionedCard(cardId: string, data: any): object { + const version = (cardVersions.get(cardId) ?? 0) + 1; + cardVersions.set(cardId, version); + return { + type: "AdaptiveCard", version: "1.5", + body: [/* card content */], + actions: [{ + type: "Action.Submit", title: "Update", + data: { ...data, _cardId: cardId, _version: version }, + }], + }; +} + +app.on("card.action" as any, async ({ activity, send }) => { + const submitted = activity.value?.action?.data ?? activity.value; + const currentVersion = cardVersions.get(submitted?._cardId); + if (submitted?._version !== currentVersion) { + await send("This card is outdated. Please use the latest version."); + return { status: 200 }; + } + // Process the update safely... +}); +``` + +**Don't:** Skip version checking even for low-traffic bots — fast double-clicks and multiple tabs cause race conditions. + +**Reverse (Teams → Slack):** Use `view_hash` from `views.open()` / `views.update()` responses natively. + +### Response pattern mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `respond({ replace_original: true, blocks })` | Return card from `card.action` handler | Inline card replacement | +| `respond({ delete_original: true })` | `deleteActivity(activityId)` | Must store activity ID | +| `respond({ response_type: 'in_channel' })` | `send(text)` | All Teams messages are visible | +| `respond({ response_type: 'ephemeral' })` | *(no equivalent)* | Redesign: 1:1 chat, Action.Execute refresh, or visible | +| `chat.update(channel, ts, ...)` | `updateActivity(activityId, activity)` | Store activity ID from send() | +| `chat.delete(channel, ts)` | `deleteActivity(activityId)` | Store activity ID from send() | +| `chat.postEphemeral(channel, user, ...)` | *(no equivalent)* | Use Action.Execute `refresh.userIds` for per-user views | +| `response_url` (30-min, 5 follow-ups) | `send()` / `updateActivity()` | No expiry, no count limit | +| Button click → `ack()` + `respond()` | `card.action` handler → return card | No ack needed | + +## pitfalls + +- **Forgetting to store activity IDs**: Unlike Slack where `channel + ts` identifies any message, Teams requires the `activityId` returned from `send()`. If you don't store it, you cannot update or delete the message later. This is the #1 migration failure for interactive patterns. +- **3-second invoke timeout**: Slack's `ack()` gave you 3 seconds to acknowledge, then `response_url` gave 30 minutes for follow-up. Teams invoke handlers must return the full response (including replacement card) within ~3 seconds. Anything longer requires the deferred pattern (return processing card, update proactively). +- **No ephemeral messages — silent behavioral change**: Code using `chat.postEphemeral()` will not error during migration — it simply has no equivalent. The migrated bot must explicitly choose an alternative strategy. Audit all `postEphemeral` calls before migration. +- **`Action.Execute` vs `Action.Submit`**: `Action.Submit` sends data to the bot but does NOT support automatic card refresh or per-user views. `Action.Execute` (Universal Actions) supports both. Always use `Action.Execute` for interactive cards that need replacement or per-user content. Requires manifest version 1.12+. +- **`refresh.userIds` limit of 60**: The per-user card refresh feature (`Action.Execute` with `refresh.userIds`) supports a maximum of 60 user IDs per card. For broader audiences, send the base card to everyone and only personalize for the acting user. +- **Card replacement only works for invoke responses**: You can only replace a card inline by returning a new card from the invoke handler. If the interaction is not an invoke (e.g., a proactive message), you must use `updateActivity()` instead. +- **`deleteActivity` may not work in all contexts**: Deleting activities works in 1:1 and group chats but may be restricted in channels depending on permissions. Test deletion behavior in your target conversation types. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/user-specific-views +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-update-activity +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-delete-activity +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages +- https://github.com/microsoft/teams.ts +- https://api.slack.com/interactivity/handling — Slack interactive responses +- https://api.slack.com/methods/chat.update — Slack chat.update +- https://api.slack.com/methods/chat.postEphemeral — Slack chat.postEphemeral + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack interactive response patterns or Teams card/message update patterns. It covers: `respond({ replace_original })` to invoke card replacement, `respond({ delete_original })` to `deleteActivity()`, `chat.update()` to `updateActivity()`, `chat.postEphemeral()` redesign strategies, deferred response patterns (processing card + proactive update), `response_url` elimination, and `Action.Execute` with `refresh.userIds` for per-user card views. For Teams → Slack, map `updateActivity` to `respond({ replace_original })`, and card refresh to ephemeral messages. Pair with `../teams/ui.adaptive-cards-ts.md` for card construction patterns, `../teams/runtime.proactive-messaging-ts.md` for deferred update infrastructure, and `events-activities-ts.md` for the underlying event/activity mapping. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack interactive response patterns (respond, replace_original, ephemeral) and Teams card/message update patterns in either direction for cross-platform bots. Cover: respond({ replace_original }) to invoke card replacement and vice versa, respond({ delete_original }) to deleteActivity, chat.update to updateActivity, chat.postEphemeral redesign strategies, response_url expiry semantics, deferred response patterns with processing indicators, Action.Execute with refresh.userIds for per-user views, reverse-direction mapping from Teams to Slack, and the 3-second invoke timeout constraint. Include side-by-side TypeScript code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/link-unfurl-preview-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/link-unfurl-preview-ts.md new file mode 100644 index 000000000..123383a73 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/link-unfurl-preview-ts.md @@ -0,0 +1,330 @@ +# link-unfurl-preview-ts + +## purpose + +Bridges Slack link unfurling (link_shared, chat.unfurl) and Teams link preview (messageHandlers) for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `app.event('link_shared')` + `chat.unfurl()` → Teams `message.ext.query-link` handler.** Slack fires a `link_shared` event and the bot calls `chat.unfurl()` asynchronously. Teams uses a compose extension handler that must return the unfurl card synchronously. The handler name in the Teams SDK is `message.ext.query-link` (or the equivalent `composeExtension/queryLink` activity). [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +2. **Manifest `composeExtensions[].messageHandlers` with domain list is required.** Unlike Slack where you register unfurl domains in the app dashboard, Teams requires them in the manifest JSON under `composeExtensions[0].messageHandlers[0].value.domains`. Only URLs matching these domains trigger unfurling. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionsmessagehandlers) +3. **Teams has a 5-second synchronous response deadline.** Slack's `link_shared` event allows async unfurling — the bot receives the event, processes it, then calls `chat.unfurl()` within 30 minutes. Teams' `query-link` is an invoke that must return the preview card within ~5 seconds. If data fetching takes longer, return a minimal card and cannot update later. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +4. **The bot must be installed in the conversation for unfurling to work.** Slack link unfurling works in any channel where the app is installed (workspace-level). Teams link unfurling only works in conversations where the bot is explicitly installed. Users may need to @mention the bot or add it to the team/chat first. [learn.microsoft.com -- Link unfurling prerequisites](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling#prerequisites) +5. **No retroactive unfurling of already-posted links.** Slack can unfurl links in messages already posted (if the app is added later). Teams only unfurls links at the time they are composed/sent. Links in existing messages are never retroactively unfurled. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +6. **Slack unfurl supports multiple links per message; Teams handles one at a time.** Slack's `link_shared` event includes an array of `links` from the message. Teams invokes the `query-link` handler once per URL. If a message contains multiple matching URLs, the handler is called multiple times. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +7. **Return an Adaptive Card (not Hero/Thumbnail) for rich previews.** Slack unfurls return attachment objects with `title`, `text`, `thumb_url`, `color`. Teams link unfurling should return Adaptive Cards for the richest preview. The response format wraps the card in a `composeExtension` result with `type: "result"`. [learn.microsoft.com -- Cards in extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling#response) +8. **Domain matching is exact — no wildcards for subdomains.** Slack unfurl domain matching supports wildcards. Teams manifest `messageHandlers.value.domains` requires exact domain entries. To match `foo.example.com` and `bar.example.com`, list both explicitly. [learn.microsoft.com -- Manifest domains](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) +9. **Slack's unfurl `is_bot_token_only` flag → not applicable.** Slack distinguishes between user-token and bot-token unfurling. Teams link unfurling always runs as the bot identity. There is no user-token mode. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +10. **Cache unfurl results where possible.** Since the 5-second deadline is strict, cache API responses for frequently unfurled URLs. Slack's async model made caching less critical. In Teams, a cache miss that takes >5 seconds means the unfurl silently fails with no preview shown. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `messageHandlers` domain config to `link_shared` event subscription (configured in the Slack app dashboard under Unfurl Domains), and preview card responses to `chat.unfurl` calls. The key advantage in reverse is that Slack's async model (`chat.unfurl` within 30 minutes) is more forgiving than Teams' 5-second synchronous deadline. Adaptive Card preview content maps to Slack unfurl attachment objects with `title`, `text`, `thumb_url`, and `color`. + +## patterns + +### link_shared → query-link handler migration + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Async unfurl — no time pressure +app.event("link_shared", async ({ event, client }) => { + const unfurls: Record = {}; + + for (const link of event.links) { + if (link.domain === "myapp.example.com") { + const match = link.url.match(/\/issues\/(\d+)/); + if (match) { + const issue = await fetchIssue(match[1]); // can take 10+ seconds + unfurls[link.url] = { + title: `Issue #${issue.id}: ${issue.title}`, + text: issue.description, + color: issue.status === "open" ? "#36a64f" : "#e01e5a", + thumb_url: issue.assignee?.avatarUrl, + footer: `Status: ${issue.status}`, + }; + } + } + } + + if (Object.keys(unfurls).length > 0) { + await client.chat.unfurl({ + ts: event.message_ts, + channel: event.channel, + unfurls, + }); + } +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Simple in-memory cache to meet 5-second deadline +const issueCache = new Map(); + +// Synchronous unfurl — must respond within 5 seconds +app.on("message.ext.query-link" as any, async ({ activity }) => { + const url: string = activity.value?.url ?? ""; + const match = url.match(/\/issues\/(\d+)/); + + if (!match) { + return { status: 200, body: {} }; // No preview for unrecognized URLs + } + + const issueId = match[1]; + let issue: any; + + // Check cache first (critical for meeting 5-second deadline) + const cached = issueCache.get(issueId); + if (cached && cached.expiry > Date.now()) { + issue = cached.data; + } else { + issue = await fetchIssue(issueId); + issueCache.set(issueId, { data: issue, expiry: Date.now() + 5 * 60_000 }); + } + + const statusColor = issue.status === "open" ? "good" : "attention"; + + return { + status: 200, + body: { + composeExtension: { + type: "result", + attachmentLayout: "list", + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: `Issue #${issue.id}: ${issue.title}`, + weight: "Bolder", + size: "Medium", + }, + { + type: "TextBlock", + text: issue.description, + wrap: true, + maxLines: 3, + }, + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [{ + type: "TextBlock", + text: `Status: **${issue.status}**`, + color: statusColor, + }], + }, + { + type: "Column", + width: "stretch", + items: [{ + type: "TextBlock", + text: issue.assignee?.name ? `Assigned: ${issue.assignee.name}` : "Unassigned", + isSubtle: true, + horizontalAlignment: "Right", + }], + }, + ], + }, + ], + actions: [{ + type: "Action.OpenUrl", + title: "View Issue", + url, + }], + }, + preview: { + contentType: "application/vnd.microsoft.card.thumbnail", + content: { + title: `Issue #${issue.id}: ${issue.title}`, + text: `Status: ${issue.status}`, + }, + }, + }], + }, + }, + }; +}); + +async function fetchIssue(id: string) { + return { id, title: "Login broken on Safari", description: "Users report...", status: "open", assignee: { name: "Alice", avatarUrl: "" } }; +} + +app.start(3978); +``` + +### Manifest domain configuration + +**Slack** — domains are configured in the Slack app dashboard under "Event Subscriptions > Unfurl Domains". + +**Teams** — domains must be in the manifest JSON: + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "myapp.example.com", + "issues.example.com" + ] + } + } + ], + "commands": [] + } + ] +} +``` + +### Unfurl mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.event('link_shared')` | `app.on('message.ext.query-link')` | Invoke-based, not event-based | +| `chat.unfurl(ts, channel, unfurls)` | Return card from handler | Synchronous response | +| Unfurl domains in app dashboard | Manifest `messageHandlers.value.domains` | JSON config, not web UI | +| Async unfurl (up to 30 min) | Synchronous (5-second deadline) | Must respond immediately | +| Multiple links in one event | One invoke per URL | Handler called N times | +| Wildcard domain matching | Exact domain matching only | List all subdomains explicitly | +| `is_bot_token_only` flag | *(not applicable)* | Always bot identity | +| Attachment unfurl format | Adaptive Card in composeExtension result | Richer card format | + +### Cache middleware best practice (Y7) + +The 5-second Teams deadline makes caching non-optional. Always use a cache layer for unfurl handlers. + +```typescript +// Reusable cache-first unfurl wrapper +const unfurlCache = new Map(); + +function withUnfurlCache( + fetchFn: (url: string) => Promise, + ttlMs: number = 300_000 // 5 min default +) { + return async (url: string): Promise => { + const cached = unfurlCache.get(url); + if (cached && cached.expires > Date.now()) { + return cached.data as T; + } + const data = await fetchFn(url); // must complete in <4 seconds + unfurlCache.set(url, { data, expires: Date.now() + ttlMs }); + return data; + }; +} + +// Usage +const cachedFetchIssue = withUnfurlCache( + async (url: string) => { + const id = url.match(/\/issues\/(\d+)/)?.[1]; + return id ? await fetchIssue(id) : null; + }, + 5 * 60_000 // 5 min TTL +); + +app.on("message.ext.query-link" as any, async ({ activity }) => { + const url: string = activity.value?.url ?? ""; + const issue = await cachedFetchIssue(url); + if (!issue) return { status: 200, body: {} }; + return buildUnfurlResponse(issue); +}); +``` + +**Best practices:** +- Set TTL based on data freshness needs (5–60 minutes) +- Pre-populate cache for known high-traffic URLs on startup +- Never make multiple API calls inside the unfurl handler — pre-fetch or batch +- For production, replace the `Map` with Redis or a shared cache + +**Don't:** Skip caching even for "fast" data sources. Network latency + cold starts can push you past 5 seconds. + +**Reverse (Teams → Slack):** Slack's 30-minute async model makes caching less critical, but still recommended for performance. + +## pitfalls + +- **Missing `messageHandlers` in manifest**: Without the `messageHandlers` array in `composeExtensions`, link unfurling never triggers. The bot receives no activity for matching URLs. This is the #1 deployment issue for link unfurling. +- **5-second deadline with no fallback**: If data fetching exceeds 5 seconds, the unfurl silently fails — no error card, no retry. Users see a plain URL with no preview. Implement aggressive caching and fast-path responses. +- **Bot must be installed in the conversation**: Unlike Slack where workspace-level app installation enables unfurling everywhere, Teams requires the bot to be installed in each team/chat where unfurling should work. Users may not understand why links aren't unfurling in some conversations. +- **No retroactive unfurling**: Existing messages with matching URLs are never unfurled when the bot is installed later. Only new messages trigger the handler. Slack supports unfurling existing messages. +- **Exact domain matching**: `*.example.com` is not supported. If your app has URLs across `app.example.com`, `api.example.com`, and `docs.example.com`, all three must be listed separately in the manifest. For apps with many subdomains, use a build-time manifest generator script (see Y15 pattern below). +- **Adaptive Card size limit**: Link preview cards are subject to the standard 28 KB Adaptive Card size limit. Keep previews concise — unfurl cards with embedded images or long descriptions may be silently truncated. + +### Domain wildcard workaround: manifest generator (Y15) + +Teams requires exact domain listing — no wildcards. For apps with many subdomains, automate manifest generation at build time. + +```typescript +// scripts/generate-manifest-domains.ts +import fs from "fs"; + +// Source of truth: your subdomain list (from config, DNS, or API) +const BASE_DOMAIN = "example.com"; +const SUBDOMAINS = ["app", "docs", "api", "staging", "portal", "admin"]; + +function generateManifestDomains(): string[] { + return SUBDOMAINS.map(sub => `${sub}.${BASE_DOMAIN}`); +} + +// Read the template manifest +const manifest = JSON.parse(fs.readFileSync("manifest.template.json", "utf8")); + +// Inject domains into composeExtensions messageHandlers +manifest.composeExtensions[0].messageHandlers[0].value.domains = generateManifestDomains(); + +// Also inject into validDomains (required for link unfurling) +manifest.validDomains = [ + ...new Set([...(manifest.validDomains ?? []), ...generateManifestDomains()]), +]; + +fs.writeFileSync("manifest.json", JSON.stringify(manifest, null, 2)); +console.log(`Generated manifest with ${SUBDOMAINS.length} domains.`); +``` + +Add to your build pipeline: `ts-node scripts/generate-manifest-domains.ts` before packaging. + +**Don't:** Try to register a single wildcard domain — Teams silently rejects it with no error message. + +**Reverse (Teams → Slack):** Slack supports `*.example.com` wildcards natively in the app dashboard. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionsmessagehandlers +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions +- https://github.com/microsoft/teams.ts +- https://api.slack.com/reference/messaging/link-unfurling — Slack link unfurling +- https://api.slack.com/methods/chat.unfurl — Slack chat.unfurl + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack link unfurling or Teams link preview. It covers: `link_shared` event to `message.ext.query-link` handler, `chat.unfurl()` to synchronous card response, manifest `messageHandlers` domain configuration, the 5-second response deadline, installation requirement, and the lack of retroactive unfurling. For Teams → Slack, map `messageHandlers` domain config to `link_shared` event subscription, and preview card responses to `chat.unfurl` calls. Pair with `../teams/ui.message-extensions-ts.md` for general message extension patterns, `../teams/runtime.manifest-ts.md` for manifest configuration, and `ui-block-kit-adaptive-cards-ts.md` for converting between Slack attachment unfurl format and Adaptive Cards. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack link unfurling (link_shared event + chat.unfurl) and Teams link preview (compose extension query-link handler, messageHandlers) in either direction for cross-platform bots. Cover: manifest messageHandlers domain configuration, the 5-second synchronous response deadline vs Slack's async model, bot installation requirement, no retroactive unfurling, exact domain matching, Adaptive Card response format, caching strategies, per-URL invocation, and reverse-direction mapping from Teams messageHandlers to Slack link_shared subscriptions and chat.unfurl calls. Include TypeScript code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/middleware-handlers-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/middleware-handlers-ts.md new file mode 100644 index 000000000..0be698e54 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/middleware-handlers-ts.md @@ -0,0 +1,328 @@ +# middleware-handlers-ts + +## purpose + +Bridges Slack Bolt middleware chains and Teams SDK handler patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack Bolt uses an explicit middleware chain: `app.use((args) => { ... await next(); })` for global middleware, and per-listener middleware as extra arguments to `app.message()`, `app.action()`, etc. Teams SDK v2 uses `app.on()` route handlers that execute in registration order with no explicit `next()` call. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +2. Slack's `next()` function must be called to pass control to the next middleware. In Teams, all matching handlers for a route execute — there is no `next()` to call. To short-circuit (prevent later handlers), return early or use a guard pattern. +3. Slack global middleware (`app.use()`) runs on EVERY request before any listener. In Teams, register a `app.on('message', ...)` handler FIRST (before other message handlers) to achieve the same effect. Handler registration order determines execution order. +4. Slack listener middleware (per-handler) like `app.message(authMiddleware, actualHandler)` has no direct Teams equivalent. Refactor as: (a) a shared guard function called at the top of each handler, (b) a wrapper/decorator function that wraps handlers, or (c) a first-registered catch-all handler that sets context. +5. Slack's `ack()` (acknowledge within 3 seconds) has NO equivalent in Teams. Teams does not require acknowledgement — the Bot Framework handles the HTTP response automatically. Remove all `ack()` calls and restructure code that splits work into "before ack" and "after ack" phases. +6. Slack's `say()` (post to the conversation where the event occurred) maps directly to Teams' `send()`. Both send a message to the current conversation. Slack's `respond()` (respond to the original webhook URL) maps to `send()` for new messages or `ctx.updateActivity()` for updating the original message. The webhook URL pattern does not exist in Teams. +7. Slack's `context` object (custom properties attached via middleware) → Teams uses the activity object and handler arguments directly. For shared state across handlers, use `app.state` or closure-scoped variables. +8. Slack error middleware (`app.error(async (error) => { ... })`) → Teams error handling via try/catch in individual handlers or a global `app.on('error', ...)` handler. The error shape differs significantly: Slack provides a destructured object `{ error, context, body }` where `context` contains bot/team metadata and `body` contains the full event payload, while Teams provides the raw `Error` object plus the activity context via handler arguments. For Teams → Slack: wrap the raw Error with context/body metadata to match Slack's shape. +9. The Java Slack SDK's formal middleware chain (`Middleware` interface with `apply(req, resp, chain)` → `chain.next(req, resp)`) is structurally identical to Express middleware. When converting Java middleware, first understand the intent, then rewrite as a Teams guard function or wrapper. +10. Slack's authorization middleware (built-in, validates tokens per workspace in multi-tenant apps) is replaced by Bot Framework JWT validation (automatic) and Azure AD authentication. Remove custom authorization middleware entirely. + +## patterns + +### Slack global middleware → Teams first-registered handler + +**Slack (before):** + +```typescript +import { App, NextFn } from '@slack/bolt'; + +const app = new App({ token: '...', signingSecret: '...' }); + +// Global middleware: runs on every request +app.use(async ({ next, logger, body }) => { + logger.info(`Request type: ${body.type}`); + const start = Date.now(); + await next(); + logger.info(`Completed in ${Date.now() - start}ms`); +}); + +// Global auth middleware +app.use(async ({ next, context, client }) => { + const authResult = await client.auth.test(); + context.botUserId = authResult.user_id; + await next(); +}); + +app.message(/hello/i, async ({ say }) => { + await say('Hi there!'); +}); +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; +import pino from 'pino'; + +const log = pino({ name: 'my-bot' }); + +const app = new App({ + logger: new ConsoleLogger('my-bot', { level: 'info' }), +}); + +// No global middleware API — use first-registered handlers instead. +// Logging is built into the Teams SDK via ConsoleLogger. +// Auth is handled automatically by Bot Framework JWT validation. + +// For cross-cutting concerns, use a wrapper function: +function withLogging Promise>( + handler: T, +): T { + return (async (...args: any[]) => { + const start = Date.now(); + try { + await handler(...args); + } finally { + log.info(`Handler completed in ${Date.now() - start}ms`); + } + }) as T; +} + +app.message( + /hello/i, + withLogging(async ({ send }) => { + await send('Hi there!'); + }), +); +``` + +### Slack listener middleware (per-handler auth) → Teams guard function + +**Slack (before):** + +```typescript +// Listener middleware: only this handler requires admin check +async function requireAdmin({ message, client, next }: any) { + const userInfo = await client.users.info({ user: message.user }); + if (userInfo.user?.is_admin) { + await next(); // allow handler to proceed + } + // Not calling next() short-circuits the chain +} + +app.message(/^!admin/, requireAdmin, async ({ message, say }) => { + await say(`Admin command received from <@${message.user}>`); +}); +``` + +**Teams (after):** + +```typescript +// Guard function: replaces listener middleware +async function isAdmin(aadObjectId: string): Promise { + // Check admin status via Graph API or custom logic + const adminIds = new Set([process.env.ADMIN_AAD_ID]); + return adminIds.has(aadObjectId); +} + +app.message(/^!admin/, async ({ activity, send }) => { + // Guard at the top of the handler (replaces middleware chain) + if (!(await isAdmin(activity.from.aadObjectId ?? ''))) { + await send('You must be an admin to use this command.'); + return; // Early return replaces "not calling next()" + } + + await send(`Admin command received from ${activity.from.name}`); +}); +``` + +### Java SDK middleware chain → Teams handler wrapper + +**Java (before):** + +```java +// Formal middleware interface +public class LoggingMiddleware implements Middleware { + @Override + public Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception { + long start = System.currentTimeMillis(); + logger.info("Processing: {}", req.getRequestType()); + Response result = chain.next(req); + logger.info("Completed in {}ms", System.currentTimeMillis() - start); + return result; + } +} + +public class RateLimitMiddleware implements Middleware { + private final RateLimiter limiter; + + @Override + public Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception { + if (!limiter.tryAcquire(req.getContext().getTeamId())) { + return Response.builder().statusCode(429).body("Rate limited").build(); + } + return chain.next(req); + } +} + +// Registration +app.use(new LoggingMiddleware()); +app.use(new RateLimitMiddleware(limiter)); +``` + +**Teams TypeScript (after):** + +```typescript +// Middleware becomes wrapper functions (no formal chain) +import pino from 'pino'; + +const log = pino({ name: 'my-bot' }); + +// Rate limiter as a guard utility +class RateLimiter { + private counts = new Map(); + + tryAcquire(key: string, limit = 10, windowMs = 60_000): boolean { + const now = Date.now(); + const entry = this.counts.get(key); + if (!entry || now > entry.resetAt) { + this.counts.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + if (entry.count >= limit) return false; + entry.count++; + return true; + } +} + +const limiter = new RateLimiter(); + +// Handler wrapper that combines logging + rate limiting +type MessageHandler = (ctx: any) => Promise; + +function withMiddleware(handler: MessageHandler): MessageHandler { + return async (ctx) => { + const start = Date.now(); + const tenantId = ctx.activity.channelData?.tenant?.id ?? 'unknown'; + + // Rate limiting (replaces RateLimitMiddleware) + if (!limiter.tryAcquire(tenantId)) { + await ctx.send('Rate limited. Please try again later.'); + return; + } + + // Logging (replaces LoggingMiddleware) + log.info({ type: ctx.activity.type }, 'Processing'); + try { + await handler(ctx); + } finally { + log.info(`Completed in ${Date.now() - start}ms`); + } + }; +} + +// Apply to handlers +app.message(/^!deploy/, withMiddleware(async ({ send }) => { + await send('Deploying...'); +})); +``` + +### Removing ack() and restructuring pre/post-ack logic + +**Slack (before):** + +```typescript +app.command('/deploy', async ({ ack, respond, command }) => { + // Must ack within 3 seconds + await ack('Starting deployment...'); + + // Slow work happens AFTER ack (Slack already got the 200 OK) + const result = await runDeployment(command.text); + await respond(`Deployment ${result.status}: ${result.url}`); +}); +``` + +**Teams (after):** + +```typescript +app.message(/^\/deploy\s*(.*)/i, async ({ send, activity }) => { + // No ack() needed — Teams handles the HTTP response + // Send an immediate response (replaces ack with message) + await send('Starting deployment...'); + + // Slow work — just do it inline, no pre/post-ack split needed + const target = activity.text?.match(/^\/deploy\s*(.*)/i)?.[1] ?? ''; + const result = await runDeployment(target); + await send(`Deployment ${result.status}: ${result.url}`); +}); +``` + +### say() → send() and error handling differences + +**Slack (before):** + +```typescript +// say() posts to the conversation where the event occurred +app.message(/help/i, async ({ say, message }) => { + await say(`Hey <@${message.user}>, here's what I can do...`); +}); + +// Global error handler — receives { error, context, body } +app.error(async ({ error, context, body }) => { + console.error(`Error in team ${context.teamId}:`, error.message); + console.error('Event body:', body.type); + // context has botUserId, teamId, etc. set by middleware + // body has the full Slack event payload +}); +``` + +**Teams (after):** + +```typescript +// send() is the Teams equivalent of say() — posts to the current conversation +app.message(/help/i, async ({ send, activity }) => { + await send(`Hey ${activity.from.name}, here's what I can do...`); +}); + +// Global error handler — receives the raw Error + activity context +app.on('error', async ({ error, activity }) => { + // Teams provides the raw Error object, not { error, context, body } + console.error(`Error in tenant ${activity?.conversation?.tenantId}:`, (error as Error).message); + console.error('Activity type:', activity?.type); + // No context bag — use activity properties directly + // No body — the activity IS the event payload +}); +``` + +### Reverse direction (Teams → Slack) + +For Teams → Slack, convert handler wrappers/guards back to formal middleware chains with `next()`. Add `ack()` calls where required. Key reverse mappings: +- Wrapper/decorator functions → `app.use(async ({ next, ... }) => { ... await next(); })` for global middleware +- Guard functions at top of handler → listener middleware: `app.message(guardMiddleware, actualHandler)` +- Early `return` for short-circuit → omit `await next()` to stop the chain +- `send()` for interim status → `ack('status message')` for immediate acknowledgement within 3 seconds +- `ctx.updateActivity()` → `respond({ replace_original: true, ... })` +- `app.on('error', ...)` → `app.error(async ({ error, context, body }) => { ... })` +- Handler registration order → explicit `app.use()` registration order for middleware chain +- Closure-scoped state / `app.state` → `context` object properties set by middleware (e.g., `context.botUserId`) +- Inline sequential work → split into pre-`ack()` (fast) and post-`ack()` (slow) phases where needed +- Bot Framework JWT validation (automatic, remove) → add `signingSecret` to Bolt config for request verification + +## pitfalls + +- **Looking for `next()`**: Teams has no middleware chain with `next()`. Every registered handler for a matching route runs. Stop thinking in chains and think in "ordered handler list." +- **Porting `ack()` as an empty response**: `ack()` is a Slack-specific 3-second HTTP response requirement. Teams has no equivalent. Remove it entirely — don't replace it with an empty `send()`. +- **Porting `respond()` URL-based replies**: Slack's `respond()` uses a `response_url` webhook. Teams has no response URL concept. Replace with `send()` for new messages or `ctx.updateActivity()` for updating existing messages. +- **Middleware that sets `context` properties**: Slack middleware often attaches custom data to `context` (e.g., `context.botUserId`). In Teams, use the handler's arguments directly (`activity.recipient.id` for bot ID) or closure-scoped state. There is no mutable `context` bag. +- **Authorization middleware being ported**: Slack's built-in `authorize` function (multi-tenant token lookup) and custom auth middleware should NOT be ported. Bot Framework JWT validation is automatic. Remove all token verification middleware. +- **Pre/post `ack()` split logic**: Slack apps commonly split handlers into "before ack" (fast, returns 200) and "after ack" (slow, async work). In Teams, this split is unnecessary — just do the work sequentially. Send an interim status message if the user needs feedback while waiting. +- **Java `MiddlewareChain.next()` return value**: Java middleware can inspect the Response returned by `chain.next()` and modify it. Teams handlers don't return responses to a chain — they call `send()` directly. Post-processing middleware must become wrapper functions. + +## references + +- https://slack.dev/bolt-js/concepts/global-middleware -- Slack Bolt global middleware +- https://slack.dev/bolt-js/concepts/listener-middleware -- Slack listener middleware +- https://api.slack.com/interactivity/handling#acknowledgment_response -- Slack ack() requirement +- https://github.com/microsoft/teams.ts -- Teams SDK v2 handler patterns +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication -- Bot Framework auth (replaces Slack signing secret) + +## instructions + +Use this expert when bridging Slack middleware patterns and Teams handler patterns in either direction. The key conceptual shift is: Slack uses a formal middleware chain with `next()` and `ack()`, while Teams uses ordered route handlers with no chain, no acknowledgement requirement, and automatic authentication. For Slack → Teams: replace global middleware with first-registered handlers or wrappers, replace listener middleware with guards, remove `ack()`, remove authorization middleware. For Teams → Slack: convert wrappers/guards back to formal middleware chains with `next()`, add `ack()` calls, add signing secret verification. Pair with `events-activities-ts.md` for the event/route mapping and `../teams/runtime.routing-handlers-ts.md` for Teams handler registration patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Bolt middleware and Teams SDK v2 handler patterns bidirectionally. Cover: global middleware (app.use with next()) <-> first-registered handlers, listener middleware <-> guard functions, ack() addition/removal strategy, respond() <-> send()/updateActivity(), Java Middleware interface <-> TypeScript wrapper functions, authorization middleware bridging, context property migration, error handling middleware, and pre/post-ack logic restructuring. Include 4 worked examples covering both directions." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/python-cross-platform.md b/skills/microsoft-365-agents-toolkit/experts/bridge/python-cross-platform.md new file mode 100644 index 000000000..8a53a2770 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/python-cross-platform.md @@ -0,0 +1,180 @@ +# python-cross-platform + +## purpose + +Unified Python server architecture for dual-platform Slack + Teams bots — combining `slack_bolt` and `microsoft_teams` in a single Python codebase. + +## rules + +1. Use **FastAPI** as the shared web framework. The Teams Python SDK uses FastAPI internally, and Slack Bolt has an `AsyncSlackRequestHandler` adapter for FastAPI. Both SDKs can share one FastAPI app and one process. [slack_bolt.adapter.fastapi, microsoft_teams.apps] +2. Mount the Slack handler at `/slack/events` and let the Teams SDK handle `/api/messages` (its default). Both endpoints run in the same FastAPI process, each routing to its own SDK. [FastAPI route mounting] +3. Use `AsyncApp` (not sync `App`) for Slack Bolt when combining with Teams, since the Teams SDK is async-only. Mixing sync Slack Bolt with async Teams SDK in one process causes event loop conflicts. [slack_bolt.async_app] +4. Build a **shared service layer** between platforms. Platform handlers call the same business logic — the Slack handler converts Slack payloads to service calls, and the Teams handler converts Teams activities to the same service calls. This mirrors the TS cross-platform architecture pattern. [experts/bridge/cross-platform-architecture-ts.md] +5. For AI features, use a single model client shared between platforms. Both `slack_bolt` handlers and `microsoft_teams` handlers can call the same OpenAI/Azure OpenAI client. Do not duplicate model initialization per platform. [shared service pattern] +6. Handle platform-specific UI by converting between Block Kit (Slack) and Adaptive Cards (Teams) at the adapter layer. The service layer returns platform-agnostic data; each platform adapter formats it for its UI framework. [experts/bridge/block-kit-to-adaptive-cards-ts.md concepts] +7. Use a single `.env` file for both platforms' credentials: `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_APP_TOKEN` (if Socket Mode) for Slack; `CLIENT_ID`, `CLIENT_SECRET` for Teams. Load with `python-dotenv`. [environment config] +8. For local development, use Slack Socket Mode (no public URL needed) alongside the Teams SDK's HTTP endpoint. The Slack `SocketModeHandler` runs in a background thread while FastAPI serves Teams on a port. [slack_bolt.adapter.socket_mode] +9. Store user identity mappings between platforms. A Slack user ID (`U...`) and a Teams user AAD object ID are different identifiers for the same person. Build a mapping table keyed by email or employee ID. [experts/bridge/identity-linking-ts.md concepts] +10. Deploy as a single container or process. Both SDKs share the Python runtime, dependencies, and service layer. Use `uvicorn` to run the FastAPI app, with Slack Socket Mode starting as a background task if needed. [deployment pattern] + +## patterns + +### Unified FastAPI server with both SDKs + +```python +import asyncio +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from microsoft_teams.apps import App as TeamsApp, ActivityContext +from microsoft_teams.api import MessageActivity + +# --- Shared service layer --- +async def handle_greeting(user_name: str) -> str: + return f"Hello, {user_name}! How can I help?" + +async def handle_status_request() -> dict: + return {"api": "healthy", "db": "healthy", "queue": "degraded"} + +# --- Slack setup --- +slack_app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], +) + +@slack_app.message("hello") +async def slack_hello(message, say): + result = await handle_greeting(f"<@{message['user']}>") + await say(result) + +@slack_app.command("/status") +async def slack_status(ack, respond): + await ack("Checking...") + status = await handle_status_request() + await respond( + f"API: {status['api']} | DB: {status['db']} | Queue: {status['queue']}" + ) + +slack_handler = AsyncSlackRequestHandler(slack_app) + +# --- Teams setup --- +teams_app = TeamsApp( + client_id=os.environ.get("CLIENT_ID"), + client_secret=os.environ.get("CLIENT_SECRET"), +) + +@teams_app.on_message_pattern(r"^hello") +async def teams_hello(ctx: ActivityContext[MessageActivity]): + user_name = ctx.activity.from_property.name or "there" + result = await handle_greeting(user_name) + await ctx.send(result) + +@teams_app.on_message_pattern(r"^status$") +async def teams_status(ctx: ActivityContext[MessageActivity]): + status = await handle_status_request() + await ctx.send( + f"API: {status['api']} | DB: {status['db']} | Queue: {status['queue']}" + ) + +# --- FastAPI combines both --- +@asynccontextmanager +async def lifespan(app: FastAPI): + # Start Teams SDK in background + asyncio.create_task(teams_app.start(port=None)) + yield + +fastapi_app = FastAPI(lifespan=lifespan) + +@fastapi_app.post("/slack/events") +async def slack_events(req: Request): + return await slack_handler.handle(req) + +# Teams registers its own /api/messages route via HttpPlugin +# Mount Teams routes into the shared FastAPI app +fastapi_app.mount("/", teams_app.http.app) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(fastapi_app, host="0.0.0.0", port=3000) +``` + +### Platform adapter pattern for UI conversion + +```python +from dataclasses import dataclass +from typing import Any + +@dataclass +class StatusCard: + """Platform-agnostic data structure""" + title: str + fields: dict[str, str] + action_label: str + +def to_slack_blocks(card: StatusCard) -> list[dict[str, Any]]: + """Convert to Slack Block Kit""" + fields = [ + {"type": "mrkdwn", "text": f"*{k}:* {v}"} + for k, v in card.fields.items() + ] + return [ + {"type": "section", "text": {"type": "mrkdwn", "text": f"*{card.title}*"}}, + {"type": "section", "fields": fields}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": card.action_label}, + "action_id": "refresh_status", + } + ], + }, + ] + +def to_adaptive_card(card: StatusCard) -> dict[str, Any]: + """Convert to Teams Adaptive Card""" + facts = [{"title": k, "value": v} for k, v in card.fields.items()] + return { + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + {"type": "TextBlock", "text": card.title, "weight": "Bolder"}, + {"type": "FactSet", "facts": facts}, + ], + "actions": [ + {"type": "Action.Submit", "title": card.action_label} + ], + } +``` + +## pitfalls + +- **Event loop conflicts**: Mixing sync Slack `App` with async Teams SDK causes `RuntimeError: This event loop is already running`. Always use `AsyncApp` for Slack when combining with Teams. +- **Port collision**: Both SDKs default to different ports (Slack: 3000, Teams: 3978). When combining, use one port for the shared FastAPI app and configure both SDKs to use it. +- **Double handling**: If both Slack and Teams are mounted on the same FastAPI app, ensure routes don't overlap. Slack uses `/slack/events`, Teams uses `/api/messages` — keep them separate. +- **Python version**: The Teams Python SDK requires **Python 3.12+**. Slack Bolt supports 3.9+. The combined project must use 3.12+ to satisfy both. +- **Credential isolation**: Never mix Slack tokens with Teams credentials. Use clear env var prefixes (`SLACK_*` for Slack, `CLIENT_*` / `AZURE_*` for Teams) to avoid accidental cross-contamination. +- **No Python-specific TS experts**: All architecture and bridging experts (`cross-platform-architecture-ts.md`, `block-kit-to-adaptive-cards-ts.md`, etc.) contain TypeScript code. Use them for design patterns but translate all code to Python. + +## references + +- https://slack.dev/bolt-python/concepts +- https://slack.dev/bolt-python/concepts/adapters +- teams.py source: packages/apps/src/microsoft_teams/apps/ +- experts/bridge/cross-platform-architecture-ts.md (patterns to adapt) +- experts/bridge/block-kit-to-adaptive-cards-ts.md (UI conversion concepts) + +## instructions + +This expert covers the unified Python server architecture for Tier 2 dual-platform bots. Use it when building a Python bot that serves both Slack and Teams from a single codebase. It covers the FastAPI integration pattern, shared service layer, platform adapters, and deployment model. + +Pair with: `slack/bolt-python.md` for Slack-side Python SDK details. `teams/teams-python.md` for Teams-side Python SDK details. `bridge/cross-platform-architecture-ts.md` for architectural patterns (translate to Python). `bridge/block-kit-to-adaptive-cards-ts.md` for UI conversion concepts (translate to Python). `bridge/identity-linking-ts.md` for user mapping concepts. + +## research + +Deep Research prompt: + +"Write a micro expert on building a unified Python server that combines Slack Bolt (slack_bolt AsyncApp) and Microsoft Teams SDK (microsoft_teams) in a single FastAPI application. Cover FastAPI route mounting (/slack/events for Slack, /api/messages for Teams), shared service layer pattern, platform adapter pattern for Block Kit vs Adaptive Cards, environment configuration for both platforms, Socket Mode for local dev alongside HTTP for Teams, async-only requirement, Python 3.12+ version constraint, deployment as single container, and identity mapping between Slack user IDs and Teams AAD object IDs. Source from slack_bolt adapter.fastapi, microsoft_teams.apps HttpPlugin, and cross-platform architecture patterns." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/rate-limiting-resilience-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/rate-limiting-resilience-ts.md new file mode 100644 index 000000000..0d670130e --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/rate-limiting-resilience-ts.md @@ -0,0 +1,367 @@ +# rate-limiting-resilience-ts + +## purpose + +Bridges Slack and Teams rate limiting patterns, retry logic, and resilience strategies for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack 429 + `Retry-After` header → same pattern for Bot Framework and Graph API.** Both platforms return HTTP 429 with a `Retry-After` header (seconds) when throttled. The retry pattern is identical: wait the specified duration, then retry. The difference is in the rate limits themselves. [learn.microsoft.com -- Graph throttling](https://learn.microsoft.com/en-us/graph/throttling) +2. **Slack Bolt retry config → manual retry with exponential backoff + jitter.** Slack Bolt has built-in retry (`retryConfig: { retries: 3 }`). The Teams SDK does not have built-in retry. Implement exponential backoff with jitter: `delay = min(baseDelay * 2^attempt + random(0, jitter), maxDelay)`. [learn.microsoft.com -- Retry guidance](https://learn.microsoft.com/en-us/azure/architecture/best-practices/retry-service-specific) +3. **Teams Bot Framework rate limits: ~1 msg/sec per conversation, ~30 msg/min per conversation.** These are soft limits that vary by channel type (1:1 vs group vs channel). Exceeding them results in 429 responses. Slack's rate limits are per-method (e.g., `chat.postMessage` at ~1/sec per token). Teams limits are per-conversation, not per-method. [learn.microsoft.com -- Bot rate limiting](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) +4. **Graph API has separate throttling from Bot Framework.** Graph API rate limits are per-app and per-tenant, varying by API. Common limits: 10,000 requests/10 minutes per app, with lower limits for specific APIs (e.g., channel messages). Graph 429s include `Retry-After` headers. These are independent of Bot Framework message rate limits. [learn.microsoft.com -- Graph throttling](https://learn.microsoft.com/en-us/graph/throttling) +5. **Proactive broadcast to many conversations needs a send queue.** Sending the same message to 500 users at once will hit rate limits. Implement a queue with concurrency control: process N messages concurrently, respect per-conversation limits, and handle 429s with retry. Use `p-limit`, `p-queue`, or a custom queue. [npmjs.com/p-queue](https://www.npmjs.com/package/p-queue) +6. **Circuit breaker pattern (`opossum`) protects against cascading failures.** When an external service (your database, a third-party API) is down, the bot should fail fast instead of timing out on every request. Use `opossum` to wrap external calls: after N failures, the circuit opens and rejects immediately for a cooldown period. [npmjs.com/opossum](https://www.npmjs.com/package/opossum) +7. **Slack `slack_api_error` with `response.headers['retry-after']` → same extraction pattern for Teams.** The error handling pattern is similar: catch HTTP errors, check for 429 status, extract `Retry-After`, and schedule retry. The API client libraries differ but the logic is identical. [learn.microsoft.com -- Graph error handling](https://learn.microsoft.com/en-us/graph/errors) +8. **Bot Framework Connector API has a separate 30-second timeout.** Beyond rate limits, the Bot Framework Connector API has a response timeout. If the bot doesn't respond to an invoke within ~3-10 seconds (depending on activity type), the Connector may retry or time out. This is separate from rate limiting but can compound issues under load. [learn.microsoft.com -- Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview) +9. **Graph API batch requests reduce API call volume.** Instead of N individual Graph API calls, batch up to 20 requests in a single `POST /$batch` call. This counts as fewer requests against rate limits and reduces network overhead. Useful for bulk channel operations, user lookups, or file operations. [learn.microsoft.com -- JSON batching](https://learn.microsoft.com/en-us/graph/json-batching) +10. **Log and monitor throttling events.** Unlike Slack where Bolt logs retries automatically, Teams throttling must be explicitly logged. Track: 429 count, average retry delay, circuit breaker state, queue depth. Use Application Insights custom metrics or console logging. Throttling spikes indicate you're approaching platform limits. [learn.microsoft.com -- App Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, Slack Bolt provides built-in `retryConfig`. Map custom Teams retry plugins to Bolt's retry configuration. Slack rate limits are per-method-per-token (not per-conversation like Teams). The `p-queue` and circuit breaker patterns apply equally in both directions. For Graph API batch requests, there is no Slack equivalent — individual API calls are needed but Bolt's built-in retry handles 429s automatically. + +## patterns + +### Exponential backoff wrapper + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + // Built-in retry handling + retryConfig: { + retries: 3, + factor: 2, // exponential backoff + }, +}); + +// Bolt automatically retries on 429 +app.message(/hello/i, async ({ say }) => { + await say("Hello!"); // auto-retried on rate limit +}); +``` + +**Teams (after) — manual retry wrapper:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const logger = new ConsoleLogger("my-bot", { level: "info" }); + +const app = new App({ + logger, +}); + +// Exponential backoff with jitter — replaces Bolt's retryConfig +async function withRetry( + fn: () => Promise, + options: { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitterMs?: number; + } = {} +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1000, + maxDelayMs = 30_000, + jitterMs = 500, + } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + const status = err?.statusCode ?? err?.response?.status ?? err?.code; + const isRetryable = status === 429 || status === 503 || status === 502; + + if (!isRetryable || attempt === maxRetries) { + throw err; + } + + // Use Retry-After header if available, otherwise exponential backoff + const retryAfterSec = err?.response?.headers?.["retry-after"]; + let delay: number; + + if (retryAfterSec) { + delay = parseInt(retryAfterSec, 10) * 1000; + } else { + delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); + } + + // Add jitter to prevent thundering herd + delay += Math.random() * jitterMs; + + logger.warn( + `Rate limited (attempt ${attempt + 1}/${maxRetries}). ` + + `Retrying in ${Math.round(delay)}ms...` + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error("withRetry: unreachable"); +} + +// Usage: wrap any API call that might be rate limited +app.message(/hello/i, async ({ send }) => { + await withRetry(() => send("Hello!")); +}); + +app.start(3978); +``` + +### Rate-limited proactive broadcast + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Broadcast to all channels — Bolt retries handle 429s +app.command("/broadcast", async ({ ack, command, client }) => { + await ack(); + const channels = await client.conversations.list({ types: "public_channel" }); + + for (const channel of channels.channels ?? []) { + try { + await client.chat.postMessage({ + channel: channel.id!, + text: command.text, + }); + } catch (err: any) { + if (err.data?.error === "ratelimited") { + const retryAfter = parseInt(err.data.response_metadata?.retry_after ?? "1", 10); + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + await client.chat.postMessage({ channel: channel.id!, text: command.text }); + } + } + } +}); +``` + +**Teams (after) — queued broadcast with concurrency control:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import PQueue from "p-queue"; + +const logger = new ConsoleLogger("my-bot", { level: "info" }); +const app = new App({ logger }); + +// Store conversation references at install time +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + }); +}); + +// Rate-limited broadcast queue +// Concurrency: 5 simultaneous sends, 200ms between each +const sendQueue = new PQueue({ + concurrency: 5, + interval: 200, + intervalCap: 1, // 1 task per interval per concurrency slot +}); + +app.message(/^\/?broadcast (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?broadcast\s+/i, "") ?? ""; + const targets = Array.from(conversationRefs.values()); + + await send(`Broadcasting to ${targets.length} conversations...`); + + let sent = 0; + let failed = 0; + + const promises = targets.map((ref) => + sendQueue.add(async () => { + try { + await withRetry(() => app.send(ref.conversationId, text)); + sent++; + } catch (err) { + failed++; + logger.error(`Failed to send to ${ref.conversationId}:`, err); + } + }) + ); + + await Promise.all(promises); + await send(`Broadcast complete: ${sent} sent, ${failed} failed.`); +}); + +// withRetry from previous pattern +async function withRetry(fn: () => Promise): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await fn(); + } catch (err: any) { + const status = err?.statusCode ?? err?.response?.status; + if (status !== 429 || attempt === 2) throw err; + const retryAfter = parseInt(err?.response?.headers?.["retry-after"] ?? "2", 10); + await new Promise((r) => setTimeout(r, retryAfter * 1000 + Math.random() * 500)); + } + } + throw new Error("unreachable"); +} + +app.start(3978); +``` + +### Circuit breaker for downstream services + +```typescript +import CircuitBreaker from "opossum"; + +// Wrap an external API call with a circuit breaker +const fetchUserData = new CircuitBreaker( + async (userId: string) => { + const response = await fetch(`https://api.internal.com/users/${userId}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + { + timeout: 5000, // If the function takes longer than 5s, trigger a failure + errorThresholdPercentage: 50, // Open circuit when 50% of requests fail + resetTimeout: 30_000, // After 30s, try again (half-open) + volumeThreshold: 5, // Minimum 5 requests before evaluating threshold + } +); + +// Circuit events for monitoring +fetchUserData.on("open", () => logger.warn("Circuit OPEN — failing fast")); +fetchUserData.on("halfOpen", () => logger.info("Circuit HALF-OPEN — testing")); +fetchUserData.on("close", () => logger.info("Circuit CLOSED — normal operation")); + +// Usage in a handler +app.message(/^\/?user (.+)$/i, async ({ send, activity }) => { + const userId = activity.text?.match(/user\s+(\S+)/)?.[1] ?? ""; + try { + const user = await fetchUserData.fire(userId); + await send(`User: ${user.name} (${user.email})`); + } catch (err: any) { + if (err.message === "Breaker is open") { + await send("The user service is temporarily unavailable. Please try again later."); + } else { + await send(`Error fetching user: ${err.message}`); + } + } +}); +``` + +### Best practice: retry utility + p-queue broadcast (Y17) + +**Always build a retry utility with exponential backoff and jitter.** Apply it to all outbound API calls. For proactive broadcasts, combine with `p-queue` concurrency control. + +```typescript +// Production retry utility — apply to all outbound calls +async function withRetry(fn: () => Promise, maxRetries = 3): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxRetries) throw err; + const retryAfter = err?.response?.headers?.["retry-after"]; + const baseDelay = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt; + const jitter = Math.random() * 1000; + await new Promise(r => setTimeout(r, baseDelay + jitter)); + } + } + throw new Error("Unreachable"); +} + +// Proactive broadcast with concurrency control +import PQueue from "p-queue"; + +const broadcastQueue = new PQueue({ concurrency: 5, interval: 200, intervalCap: 1 }); + +async function broadcastToAll( + conversationIds: string[], + message: string +): Promise<{ sent: number; failed: number }> { + let sent = 0, failed = 0; + + const promises = conversationIds.map(convId => + broadcastQueue.add(async () => { + try { + await withRetry(() => app.send(convId, message)); + sent++; + } catch { + failed++; + } + }) + ); + + await Promise.all(promises); + return { sent, failed }; +} +``` + +**Key rules:** +- **Always add jitter.** Without it, multiple bot instances retry simultaneously (thundering herd). +- **Set a max queue depth.** Unbounded queues accumulate thousands of items in memory. +- **Treat 503 the same as 429.** Both are retryable with backoff. + +**Don't:** Retry without jitter, or use Bolt's `retryConfig` and assume it covers Graph API calls (it only covers Slack API calls). + +**Reverse (Teams → Slack):** Configure Bolt's built-in `retryConfig: { retries: 3, factor: 2 }` for Slack API calls. The `p-queue` pattern applies equally for Slack broadcasts. + +### Rate limit comparison table + +| Aspect | Slack | Teams Bot Framework | Teams Graph API | +|---|---|---|---| +| Rate limit scope | Per-method per-token | Per-conversation | Per-app per-tenant | +| Message send limit | ~1/sec per token | ~1/sec per conversation | N/A (use Bot Framework) | +| Throttle response | HTTP 429 + `Retry-After` | HTTP 429 + `Retry-After` | HTTP 429 + `Retry-After` | +| Built-in retry (SDK) | Bolt `retryConfig` | None (manual) | None (manual) | +| Batch API | N/A | N/A | `POST /$batch` (up to 20) | +| Burst limit | ~30/min per token | ~30/min per conversation | Varies by API | + +## pitfalls + +- **No built-in retry in Teams SDK**: Slack Bolt's `retryConfig` automatically retries rate-limited requests. The Teams SDK has no equivalent. You must implement retry logic yourself or use a library wrapper. +- **Per-conversation vs per-token limits**: Slack rate limits are per-method-per-token (global). Teams Bot Framework limits are per-conversation. Sending to 100 different conversations simultaneously is fine; sending 100 messages to the same conversation will be throttled. +- **Graph API and Bot Framework throttling are independent**: A bot can be rate-limited on Graph API calls (user lookups, channel operations) while Bot Framework message sends are fine, or vice versa. Implement retry logic for both independently. +- **Thundering herd on retry**: Without jitter, all rate-limited requests retry at exactly the same time, causing another burst. Always add random jitter to retry delays. +- **Queue depth unbounded**: Using `p-queue` without a size limit can accumulate thousands of pending messages in memory. Set a maximum queue size and reject new items when full (with a user-facing error). +- **Circuit breaker not covering all dependencies**: The circuit breaker should wrap every external dependency (database, third-party API, Graph API) — not just one. A bot with an unprotected dependency can still cascade-fail. +- **Forgetting to handle 503 Service Unavailable**: In addition to 429, Bot Framework may return 503 during outages. Treat 503 the same as 429 (retryable with backoff). + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit +- https://learn.microsoft.com/en-us/graph/throttling +- https://learn.microsoft.com/en-us/graph/json-batching +- https://learn.microsoft.com/en-us/azure/architecture/best-practices/retry-service-specific +- https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs +- https://www.npmjs.com/package/p-queue +- https://www.npmjs.com/package/opossum +- https://github.com/microsoft/teams.ts +- https://api.slack.com/docs/rate-limits — Slack rate limits + +## instructions + +Use this expert when adding cross-platform support in either direction for rate limiting and resilience. It covers: Slack Bolt `retryConfig` bridged to Teams manual exponential backoff + jitter, Teams Bot Framework per-conversation rate limits, Graph API per-app throttling, proactive broadcast with send queue concurrency control, circuit breaker pattern with `opossum`, Graph API batch requests, monitoring throttling events, and reverse mapping from custom Teams retry logic back to Bolt's built-in retry configuration. Pair with `../teams/runtime.proactive-messaging-ts.md` for proactive send infrastructure, `../teams/graph.usergraph-appgraph-ts.md` for Graph API patterns, and `scheduling-deferred-send-ts.md` for rate-limited scheduled sends. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack and Teams rate limiting patterns, retry logic, and resilience strategies in either direction. Cover: Bolt retryConfig vs manual exponential backoff + jitter, Teams Bot Framework per-conversation rate limits (1 msg/sec, 30 msg/min), Graph API per-app throttling, proactive broadcast send queues with concurrency control, circuit breaker pattern with opossum, Graph API $batch for reducing call volume, 429/503 retry handling, monitoring, and reverse mapping from Teams retry patterns back to Slack Bolt's built-in retry configuration. Include TypeScript code examples and a comparison table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/rest-only-integration-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/rest-only-integration-ts.md new file mode 100644 index 000000000..bfd40be2b --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/rest-only-integration-ts.md @@ -0,0 +1,150 @@ +# rest-only-integration-ts + +## purpose + +Raw HTTP integration patterns for Teams and Slack without native SDKs — Bot Framework REST API for Teams, Slack Events API + Web API for Slack. For Java, C#, Go, or any language that lacks an official Bolt or Teams SDK. + +## rules + +1. **Use the Bot Framework REST API for Teams when no SDK is available.** The REST API is language-agnostic. Authenticate via Azure AD OAuth2 client credentials, then POST activities to the Bot Connector service URL. +2. **Use the Slack Events API + Web API for Slack when no Bolt SDK is available.** Receive events via HTTP POST webhooks (with signature verification), respond via `chat.postMessage` and other Web API methods. +3. **Verify Slack request signatures manually.** Compute `HMAC-SHA256` of `v0:{timestamp}:{request_body}` using your signing secret. Compare against the `X-Slack-Signature` header. Reject if timestamp is older than 5 minutes. +4. **Verify Teams JWT tokens manually.** Validate the `Authorization: Bearer ` header against Azure AD's OpenID configuration. Check `iss`, `aud` (your app ID), and token expiration. Use your platform's JWT library. +5. **Acquire Bot Framework tokens via Azure AD.** POST to `https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token` with `client_id`, `client_secret`, and `scope=https://api.botframework.com/.default`. +6. **Send Teams messages via the Bot Connector API.** POST to `{serviceUrl}/v3/conversations/{conversationId}/activities` with the activity JSON and a `Bearer` token. The `serviceUrl` comes from the inbound activity. +7. **Send Slack messages via the Web API.** POST to `https://slack.com/api/chat.postMessage` with `Authorization: Bearer xoxb-...` and a JSON body containing `channel`, `text`, and optionally `blocks`. +8. **Acknowledge Slack events within 3 seconds.** Return HTTP 200 immediately, then process async. For interactions (actions, commands, shortcuts), return a JSON body or empty 200 to acknowledge. +9. **Handle the Slack URL verification challenge.** When Slack sends `{ type: "url_verification", challenge: "..." }`, respond with `{ challenge: "..." }` and HTTP 200. This only happens once during setup. +10. **Return HTTP 200/201 for Teams webhook POSTs.** The Bot Framework expects a 200 response. For invoke activities, return a JSON body with `{ status: 200, body: ... }`. +11. **Store the `serviceUrl` from Teams activities.** Each inbound activity includes a `serviceUrl` that may change. Use it for subsequent API calls to that conversation. Cache per conversation. +12. **Use `response_url` for Slack interaction responses.** Actions, commands, and shortcuts include a `response_url`. POST to it within 30 minutes with `{ text, response_type }` for follow-up messages without needing the Web API. + +## patterns + +### Slack signature verification (pseudocode, any language) + +``` +function verifySlackSignature(signingSecret, timestamp, body, signature): + if abs(now() - timestamp) > 300: // 5 minutes + return false + basestring = "v0:" + timestamp + ":" + body + computed = "v0=" + hmac_sha256(signingSecret, basestring) + return timingSafeCompare(computed, signature) + +// HTTP handler: +timestamp = request.headers["X-Slack-Request-Timestamp"] +signature = request.headers["X-Slack-Signature"] +if not verifySlackSignature(SECRET, timestamp, rawBody, signature): + return 401 +``` + +### Teams token acquisition (HTTP, any language) + +``` +POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +client_id={appId} +&client_secret={appPassword} +&scope=https://api.botframework.com/.default +&grant_type=client_credentials + +Response: { "access_token": "eyJ...", "expires_in": 3600 } +``` + +### Send a Teams message (HTTP, any language) + +``` +POST {serviceUrl}/v3/conversations/{conversationId}/activities +Authorization: Bearer {access_token} +Content-Type: application/json + +{ + "type": "message", + "text": "Hello from a REST client!", + "from": { "id": "{botAppId}", "name": "My Bot" } +} +``` + +### Send a Slack message (HTTP, any language) + +``` +POST https://slack.com/api/chat.postMessage +Authorization: Bearer xoxb-your-token +Content-Type: application/json + +{ + "channel": "C123ABC", + "text": "Hello from a REST client!", + "blocks": [ + { + "type": "section", + "text": { "type": "mrkdwn", "text": "Hello from a REST client!" } + } + ] +} +``` + +### Slack event webhook handler (pseudocode) + +``` +function handleSlackEvent(request): + verifySignature(request) + body = parseJSON(request.body) + + if body.type == "url_verification": + return { challenge: body.challenge } + + if body.type == "event_callback": + event = body.event + // Process event async... + return 200 // acknowledge immediately + + if body.type == "interactive": + // action, shortcut, or view_submission + return 200 // ack, then use response_url for follow-up +``` + +### Teams JWT validation (pseudocode) + +``` +function validateTeamsJWT(authHeader, appId): + token = authHeader.replace("Bearer ", "") + // Fetch keys from https://login.botframework.com/v1/.well-known/openidconfiguration + claims = jwt_verify(token, publicKeys) + assert claims.aud == appId + assert claims.iss starts with "https://api.botframework.com" + assert claims.exp > now() + return claims +``` + +## pitfalls + +- **Slack signature uses raw body, not parsed JSON.** You must verify against the exact bytes received, not a re-serialized JSON string. Many frameworks parse the body before your handler — use middleware to capture the raw body. +- **Teams `serviceUrl` varies by region.** Don't hardcode it. The URL may be `https://smba.trafficmanager.net/...` or `https://emea.ng.msg.teams.microsoft.com/...` depending on the tenant's region. +- **Bot Framework tokens expire after 1 hour.** Cache the token and refresh before expiry. Don't acquire a new token for every outbound message — this adds latency and hits rate limits. +- **Slack's `response_url` expires after 30 minutes.** If you need to update a message later, use `chat.update` with the message `ts` instead. +- **Teams proactive messaging requires a conversation reference.** You can't just POST to a user ID — you need the `conversationId` and `serviceUrl` from a previous inbound activity. Store these on first contact. +- **No Adaptive Card support via REST without the schema.** You must construct the full Adaptive Card JSON yourself. Use the Adaptive Card Designer (https://adaptivecards.io/designer/) to prototype, then embed the JSON in your API calls. +- **Slack interactive payload is form-encoded, not JSON.** Actions, shortcuts, and view submissions arrive as `application/x-www-form-urlencoded` with a `payload` field containing JSON. Parse the `payload` field, not the raw body. + +## references + +- Bot Framework REST API: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference +- Slack Events API: https://api.slack.com/events-api +- Slack Web API: https://api.slack.com/web +- Slack request verification: https://api.slack.com/authentication/verifying-requests-from-slack +- Azure AD token endpoint: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow +- Adaptive Card Designer: https://adaptivecards.io/designer/ + +## instructions + +Use this expert when integrating with Teams or Slack from a language that lacks a native SDK (Java for Teams, C# for Slack, Go, Ruby, etc.), or when building a lightweight integration that doesn't warrant a full SDK dependency. The patterns use pseudocode that's translatable to any language. + +Pair with: `cross-platform-architecture-ts.md` (if also using TS for one platform), `../teams/runtime.app-init-ts.md` (for TS Teams SDK comparison), `../slack/runtime.bolt-foundations-ts.md` (for TS Slack SDK comparison). + +## research + +Deep Research prompt: + +"Document raw HTTP integration patterns for Microsoft Teams Bot Framework and Slack without native SDKs. Cover: Bot Framework REST Connector API (POST activities, GET conversations), Azure AD client credentials OAuth2 token acquisition, JWT validation for inbound webhooks, Slack Events API webhook setup (URL verification challenge, event_callback processing), Slack Web API methods (chat.postMessage, chat.update, views.open), Slack request signature verification (HMAC-SHA256, timing-safe comparison, timestamp validation), response_url for interaction follow-ups, Adaptive Card JSON construction for REST, Block Kit JSON construction for REST, serviceUrl caching for Teams, and rate limiting considerations." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/scheduling-deferred-send-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/scheduling-deferred-send-ts.md new file mode 100644 index 000000000..386a92fd6 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/scheduling-deferred-send-ts.md @@ -0,0 +1,336 @@ +# scheduling-deferred-send-ts + +## purpose + +Bridges Slack scheduling (chat.scheduleMessage, reminders) and Teams deferred delivery patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `chat.scheduleMessage` has NO Teams equivalent.** Teams has no built-in scheduled message API. Replace with: store the message + target time in persistent storage, then use a timer mechanism to send proactively at the scheduled time via `app.send(conversationId, message)`. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +2. **Slack `chat.deleteScheduledMessage` → delete from your own storage/queue.** Since scheduled messages are self-managed in Teams, cancellation is simply removing the pending item from your storage (database row, queue message, cron job). No platform API call needed. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. **Slack `reminders.add` → persistent storage + background poll + proactive send.** Slack reminders are platform-managed with DM delivery. In Teams, the bot must: (a) store the reminder with target user/time, (b) poll or use a timer to detect due reminders, (c) send a proactive message to the user's 1:1 chat. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +4. **In-process timers (`node-cron`, `setTimeout`) are for development only.** `node-cron` or `setTimeout` work for local dev and single-instance deployments. They are NOT durable — a process restart loses all scheduled items. Never use in-process timers for production scheduled messages. [npmjs.com/node-cron](https://www.npmjs.com/package/node-cron) +5. **Azure Functions timer trigger provides durable serverless scheduling.** Create a timer-triggered function that polls your database for due messages and sends them proactively. The CRON expression configures frequency (e.g., `"0 */1 * * * *"` for every minute). Azure manages the timer lifecycle across restarts. [learn.microsoft.com -- Timer trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer) +6. **Azure Queue Storage with visibility timeout enables exact-time scheduling.** Enqueue a message with `visibilityTimeout` set to the delay duration. The message becomes visible at the target time, triggering a queue-triggered function that sends the proactive message. Maximum visibility timeout is 7 days. [learn.microsoft.com -- Queue trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-trigger) +7. **Azure Service Bus scheduled messages support exact-time delivery.** `ServiceBusSender.scheduleMessages(message, scheduledEnqueueTimeUtc)` enqueues with a future delivery time. No polling needed — Service Bus delivers at the exact time. Supports cancellation via `cancelScheduledMessage(sequenceNumber)`. Best for high-volume scheduled sends. [learn.microsoft.com -- Service Bus scheduling](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sequencing#scheduled-messages) +8. **Power Automate "Recurrence" trigger is a no-code alternative.** For simple recurring messages (daily standup reminder, weekly digest), a Power Automate flow with a Recurrence trigger can send messages via the bot's webhook or Graph API without code. Good for business users managing their own schedules. [learn.microsoft.com -- Power Automate Recurrence](https://learn.microsoft.com/en-us/power-automate/triggers-introduction#recurrence-trigger) +9. **Store conversation references at install time for proactive messaging.** All scheduled/reminder sends require a valid conversation reference (including `serviceUrl`). Capture and persist the reference in the `install.add` handler. Without it, the bot cannot send proactive messages at scheduled time. [learn.microsoft.com -- Conversation reference](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages#get-the-conversation-reference) +10. **Rate limiting applies to bulk scheduled sends.** Teams limits bots to ~1 message/second per conversation and ~30 messages/minute per conversation. If many scheduled messages are due at the same time (e.g., "send daily digest to 500 users at 9 AM"), implement a send queue with concurrency control and staggered delivery. [learn.microsoft.com -- Rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, this is simpler — Slack has native `chat.scheduleMessage` and `reminders.add` APIs. Timer-based infrastructure (Azure Functions, Queue Storage, Service Bus) can be replaced with direct Slack API calls. Map proactive send patterns to `chat.scheduleMessage` with a `post_at` Unix timestamp. Map Power Automate recurrence flows to Slack Workflow Builder scheduled triggers or `reminders.add` for user-facing reminders. + +## patterns + +### node-cron + proactive messaging (development / single-instance) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Schedule a message for 30 minutes from now +app.command("/remind", async ({ ack, command, client }) => { + await ack(); + const postAt = Math.floor(Date.now() / 1000) + 30 * 60; + const result = await client.chat.scheduleMessage({ + channel: command.channel_id, + text: command.text, + post_at: postAt, + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: `Reminder set! ID: ${result.scheduled_message_id}`, + }); +}); + +// Cancel a scheduled message +app.command("/cancel-remind", async ({ ack, command, client }) => { + await ack(); + await client.chat.deleteScheduledMessage({ + channel: command.channel_id, + scheduled_message_id: command.text.trim(), + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: "Reminder cancelled.", + }); +}); +``` + +**Teams (after) — node-cron for dev:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import cron from "node-cron"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// In-memory store (replace with database in production) +const scheduledMessages = new Map(); + +// Store conversation references at install time +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + }); +}); + +// Schedule a reminder +app.message(/^\/?remind (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?remind\s+/i, "") ?? ""; + const convId = activity.conversation?.id ?? ""; + const id = `rem_${Date.now()}`; + const sendAt = new Date(Date.now() + 30 * 60_000); // 30 min from now + + scheduledMessages.set(id, { conversationId: convId, text, sendAt }); + + // Schedule with node-cron (NOT durable — dev only) + const task = cron.schedule( + cronFromDate(sendAt), + async () => { + await app.send(convId, text); + scheduledMessages.delete(id); + task.stop(); + }, + { scheduled: true } + ); + + scheduledMessages.get(id)!.cronTask = task; + await send(`Reminder set for ${sendAt.toISOString()}. ID: ${id}`); +}); + +// Cancel a reminder +app.message(/^\/?cancel-remind (\S+)$/i, async ({ send, activity }) => { + const id = activity.text?.match(/cancel-remind\s+(\S+)/i)?.[1] ?? ""; + const item = scheduledMessages.get(id); + if (item) { + item.cronTask?.stop(); + scheduledMessages.delete(id); + await send("Reminder cancelled."); + } else { + await send("Reminder not found."); + } +}); + +function cronFromDate(date: Date): string { + return `${date.getMinutes()} ${date.getHours()} ${date.getDate()} ${date.getMonth() + 1} *`; +} + +app.start(3978); +``` + +### Azure Functions timer + Cosmos DB (production) + +**Timer-triggered function (polls for due messages):** + +```typescript +// src/functions/sendScheduledMessages.ts +import { app as azFunc, InvocationContext, Timer } from "@azure/functions"; +import { CosmosClient } from "@azure/cosmos"; + +const cosmos = new CosmosClient(process.env.COSMOS_CONNECTION!); +const container = cosmos.database("botdb").container("scheduled-messages"); + +// Runs every minute — checks for due scheduled messages +azFunc.timer("sendScheduledMessages", { + schedule: "0 */1 * * * *", // every minute + handler: async (timer: Timer, context: InvocationContext) => { + const now = new Date().toISOString(); + + // Query for messages due now or overdue + const { resources: dueMessages } = await container.items + .query({ + query: "SELECT * FROM c WHERE c.sendAt <= @now AND c.status = 'pending'", + parameters: [{ name: "@now", value: now }], + }) + .fetchAll(); + + for (const msg of dueMessages) { + try { + // Send proactive message via Teams bot + // In practice, import your Teams app instance and call app.send() + await sendProactiveMessage(msg.conversationId, msg.text, msg.serviceUrl); + + // Mark as sent + await container.item(msg.id, msg.conversationId).replace({ + ...msg, + status: "sent", + sentAt: new Date().toISOString(), + }); + } catch (err) { + context.error(`Failed to send scheduled message ${msg.id}:`, err); + // Mark as failed for retry + await container.item(msg.id, msg.conversationId).replace({ + ...msg, + status: "failed", + error: String(err), + }); + } + } + + context.log(`Processed ${dueMessages.length} scheduled messages.`); + }, +}); + +async function sendProactiveMessage(conversationId: string, text: string, serviceUrl: string) { + // Use Bot Framework REST API or your Teams app instance + // POST to {serviceUrl}/v3/conversations/{conversationId}/activities + const response = await fetch(`${serviceUrl}/v3/conversations/${conversationId}/activities`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await getBotToken()}`, + }, + body: JSON.stringify({ + type: "message", + text, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); +} + +async function getBotToken(): Promise { + // Obtain token via client credentials flow + return "..."; +} +``` + +**Scheduling endpoint (called from the bot handler):** + +```typescript +// In your bot handler — schedule a message +app.message(/^\/?remind (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?remind\s+/i, "") ?? ""; + const convId = activity.conversation?.id ?? ""; + const sendAt = new Date(Date.now() + 30 * 60_000); + + // Persist to Cosmos DB — timer function will pick it up + await container.items.create({ + id: `rem_${Date.now()}`, + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + text, + sendAt: sendAt.toISOString(), + status: "pending", + createdBy: activity.from?.aadObjectId, + }); + + await send(`Reminder set for ${sendAt.toISOString()}.`); +}); +``` + +### Azure Service Bus scheduled messages (R7 — production, exact-time) + +The most production-ready approach for exact-time delivery with native cancellation support. + +```typescript +import { ServiceBusClient } from "@azure/service-bus"; + +const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION!); +const sender = sbClient.createSender("scheduled-messages"); + +// Schedule a message for exact-time delivery +async function scheduleMessage( + conversationId: string, text: string, sendAt: Date +): Promise { + const [sequenceNumber] = await sender.scheduleMessages( + { body: { conversationId, text } }, + sendAt + ); + return sequenceNumber; // store this for cancellation +} + +// Cancel a scheduled message +async function cancelScheduled(sequenceNumber: Long): Promise { + await sender.cancelScheduledMessages(sequenceNumber); +} + +// Receiver (runs as a separate process or Azure Function) +const receiver = sbClient.createReceiver("scheduled-messages"); +receiver.subscribe({ + processMessage: async (msg) => { + const { conversationId, text } = msg.body; + await app.send(conversationId, text); + }, + processError: async (err) => console.error(err), +}); +``` + +**Bot handler integration:** + +```typescript +app.message(/^\/?schedule (.+) at (.+)$/i, async ({ send, activity }) => { + const match = activity.text?.match(/schedule (.+) at (.+)/i); + const text = match?.[1] ?? ""; + const sendAt = new Date(match?.[2] ?? ""); + const convId = activity.conversation?.id ?? ""; + + const seqNum = await scheduleMessage(convId, text, sendAt); + // Store seqNum in database for cancellation + await send(`Scheduled for ${sendAt.toISOString()}. Cancel ID: ${seqNum}`); +}); +``` + +**When to use Service Bus vs other approaches:** +- **Service Bus:** High-volume, exact-time delivery, native cancellation. Best overall. +- **Queue Storage:** Simple delays under 7 days. Cheaper. No native cancellation. +- **Cosmos DB + Timer:** Unlimited delay. Minute-level precision. Most flexible. + +**Reverse (Teams → Slack):** Use `chat.scheduleMessage({ channel, text, post_at })` natively. + +### Scheduling approach comparison + +| Approach | Durability | Precision | Max Delay | Cancellation | Best For | +|---|---|---|---|---|---| +| `setTimeout` / `node-cron` | None (lost on restart) | ~1 sec | Unlimited | In-memory | Dev only | +| Azure Functions timer | Durable | ~1 min (poll interval) | Unlimited | Delete DB row | General production | +| Queue Storage visibility timeout | Durable | ~seconds | 7 days | Delete queue message | Short delays, simple | +| Service Bus scheduled messages | Durable | ~seconds | Unlimited | `cancelScheduledMessage()` | High-volume, exact-time | +| Power Automate Recurrence | Durable | ~1 min | Unlimited | Disable flow | No-code recurring | + +## pitfalls + +- **In-process timers are not durable**: `setTimeout` and `node-cron` lose all scheduled items on process restart, deployment, or scaling event. Never use for production. This is the #1 migration failure — developers assume their timer survives restarts like Slack's `scheduleMessage`. +- **Missing conversation reference at send time**: Proactive messaging requires a valid `serviceUrl` and `conversationId` stored at install time. If the bot hasn't stored these, it cannot send scheduled messages. Always persist conversation references in the `install.add` handler. +- **Rate limiting on bulk sends**: Sending 500 scheduled messages at 9:00 AM will hit the ~1 msg/sec/conversation limit. Implement a staggered send queue with delays between messages. Service Bus or Queue Storage with staggered visibility timeouts helps distribute load. +- **Timer function CRON precision**: Azure Functions timer triggers run at CRON intervals (e.g., every minute), not at exact timestamps. A message scheduled for 9:00:30 may not send until 9:01:00. For higher precision, use Queue Storage visibility timeout or Service Bus scheduled messages. +- **Queue Storage 7-day visibility timeout limit**: Messages with visibility timeout > 7 days silently default to 7 days. For long-horizon scheduling (weeks/months), use a database + timer function approach instead. +- **Power Automate requires premium license for custom connectors**: Sending via the bot's API requires a custom connector or HTTP action in Power Automate, which may need a premium license depending on the organization's plan. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer +- https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-trigger +- https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sequencing#scheduled-messages +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit +- https://learn.microsoft.com/en-us/power-automate/triggers-introduction +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/chat.scheduleMessage — Slack scheduled messages +- https://api.slack.com/methods/reminders.add — Slack reminders + +## instructions + +Use this expert when adding cross-platform support in either direction for scheduled messages, reminders, and deferred delivery. It covers: Slack `chat.scheduleMessage` bridged to Teams timer + proactive send, `reminders.add` bridged to persistent storage patterns, in-process timers (dev), Azure Functions timer triggers (production), Queue Storage visibility timeout, Service Bus scheduled messages, Power Automate Recurrence, rate limiting for bulk sends, conversation reference storage requirements, and reverse mapping from Teams deferred patterns back to Slack native scheduling APIs. Pair with `../teams/runtime.proactive-messaging-ts.md` for proactive messaging infrastructure, `../teams/state.storage-patterns-ts.md` for persisting scheduled items, and `slack-interactive-responses-to-teams-ts.md` for deferred response patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack scheduled messages (chat.scheduleMessage, chat.deleteScheduledMessage) and reminders (reminders.add) with Microsoft Teams deferred delivery patterns in either direction. Cover: proactive messaging with stored conversation references, in-process timers (node-cron/setTimeout) for dev, Azure Functions timer trigger for production, Queue Storage visibility timeout, Service Bus scheduled messages, Power Automate Recurrence, rate limiting for bulk sends, cancellation patterns, and reverse mapping from Teams deferred infrastructure back to Slack native scheduling APIs. Include TypeScript code examples and a comparison table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/shortcuts-extensions-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/shortcuts-extensions-ts.md new file mode 100644 index 000000000..0e611b6bb --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/shortcuts-extensions-ts.md @@ -0,0 +1,358 @@ +# shortcuts-extensions-ts + +## purpose + +Bridges Slack shortcuts (global and message) and Teams message extensions / compose extensions for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack global shortcuts → Teams action-based compose extensions with `context: ['compose', 'commandBox']`.** Slack global shortcuts appear in the lightning bolt menu and don't reference a specific message. In Teams, the equivalent is a compose extension with `fetchTask: true` and action context targeting the compose box and command bar. [learn.microsoft.com -- Action-based extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command) +2. **Slack message shortcuts → Teams action-based extensions with `context: ['message']`.** Slack message shortcuts appear in the message context menu (⋮ → More actions). In Teams, action-based extensions with `context: ['message']` appear in the message overflow menu (... → More actions). The target message content is available in the invoke payload. [learn.microsoft.com -- Message context](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command#choose-action-command-invoke-locations) +3. **Slack `trigger_id` + `views.open()` → Teams `fetchTask: true` + task module.** Slack shortcuts use the `trigger_id` to open a modal. Teams action-based extensions use `fetchTask: true` in the manifest, which causes Teams to invoke the bot's `message.ext.open` handler to fetch the task module (dialog) content. No trigger_id needed. [learn.microsoft.com -- Task module from extension](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/create-task-module) +4. **Slack `shortcut.message` (target message in message shortcuts) → Teams `activity.value.messagePayload`.** When a message shortcut is invoked in Slack, the message object is in `shortcut.message`. In Teams, the message that was acted upon is in `activity.value.messagePayload` with `id`, `body.content`, `from`, `createdDateTime`, and `attachments`. [learn.microsoft.com -- Message payload](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command#payload-activity-properties-when-invoked-from-a-message) +5. **Manifest `composeExtensions[].commands[]` with `type: "action"` is required.** Unlike Slack where shortcuts are configured in the app dashboard, Teams requires each action command to be declared in the manifest JSON with its title, description, parameters, and context array. Without this, the extension never appears. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionscommands) +6. **Slack `app.shortcut('callback_id')` → Teams handler routing via `activity.value.commandId`.** Slack routes shortcuts by `callback_id`. Teams invokes the same `message.ext.open` handler for all action commands — differentiate by checking `activity.value.commandId` against the command `id` in the manifest. [learn.microsoft.com -- Handle action](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit) +7. **Task module response replaces Slack modal view return.** Slack's `views.open()` returns a view object with blocks. Teams' `message.ext.open` handler returns a task module response containing either an Adaptive Card or an iframe URL. The Adaptive Card path is closest to Slack's modal behavior. [learn.microsoft.com -- Task modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules) +8. **Slack `view_submission` → Teams `message.ext.submit`.** When the user submits the task module form, Teams invokes the `message.ext.submit` handler (or `composeExtension/submitAction` activity). The form data is in `activity.value.data`. The handler can return a card to insert into the compose box, send a message, or show another task module. [learn.microsoft.com -- Handle submit](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit) +9. **No "fire and forget" shortcuts in Teams.** Slack global shortcuts can trigger background actions without showing a modal (just `ack()` + do work). Teams action-based extensions always show a task module if `fetchTask: true`. To mimic fire-and-forget, return a minimal confirmation card from the task module and process in the background. [learn.microsoft.com -- Action commands](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command) +10. **Teams action extensions can insert cards into the compose box.** Slack shortcuts post messages via `say()` or `respond()`. Teams action extensions can return a card that gets inserted into the user's compose box for them to review and send. This is a UX improvement — the user controls when the message is posted. Return `{ composeExtension: { type: 'result', attachments: [...] } }` from the submit handler. [learn.microsoft.com -- Respond to submit](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit#respond-with-an-adaptive-card-message-sent-from-a-bot) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map compose extensions to `app.shortcut` with `global_shortcut` or `message_shortcut` type. Action-based extensions with `context: ['compose', 'commandBox']` map to Slack global shortcuts; extensions with `context: ['message']` map to Slack message shortcuts. Task module forms become Slack modals opened via `views.open()` with a `trigger_id`. The `message.ext.submit` handler maps to a Slack `view_submission` handler. + +## patterns + +### Message shortcut → action-based message extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Message shortcut — appears in message context menu +app.shortcut("create_ticket_from_message", async ({ ack, shortcut, client }) => { + await ack(); + + const message = (shortcut as any).message; + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: "modal", + callback_id: "ticket_from_message", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Create" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Ticket Title" }, + element: { + type: "plain_text_input", + action_id: "title_input", + initial_value: message.text?.substring(0, 100) ?? "", + }, + }, + { + type: "input", + block_id: "priority_block", + label: { type: "plain_text", text: "Priority" }, + element: { + type: "static_select", + action_id: "priority_select", + options: [ + { text: { type: "plain_text", text: "High" }, value: "high" }, + { text: { type: "plain_text", text: "Medium" }, value: "medium" }, + { text: { type: "plain_text", text: "Low" }, value: "low" }, + ], + }, + }, + ], + private_metadata: JSON.stringify({ + channel: message.channel, + messageTs: message.ts, + }), + }, + }); +}); + +app.view("ticket_from_message", async ({ ack, view, client }) => { + await ack(); + const title = view.state.values.title_block.title_input.value!; + const priority = view.state.values.priority_block.priority_select.selected_option?.value; + const meta = JSON.parse(view.private_metadata); + await client.chat.postMessage({ + channel: meta.channel, + text: `Ticket created: *${title}* [${priority}]`, + thread_ts: meta.messageTs, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// message.ext.open — returns task module (replaces views.open with trigger_id) +app.on("message.ext.open" as any, async ({ activity }) => { + const commandId = activity.value?.commandId; + + if (commandId === "createTicketFromMessage") { + // Target message content (replaces shortcut.message) + const messagePayload = activity.value?.messagePayload; + const messageText = messagePayload?.body?.content ?? ""; + + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Create Ticket", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "Input.Text", + id: "ticketTitle", + label: "Ticket Title", + value: messageText.substring(0, 100), + isRequired: true, + }, + { + type: "Input.ChoiceSet", + id: "priority", + label: "Priority", + value: "medium", + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [{ + type: "Action.Submit", + title: "Create", + }], + }, + }, + }, + }, + }, + }; + } +}); + +// message.ext.submit — handle form submission (replaces app.view handler) +app.on("message.ext.submit" as any, async ({ activity, send }) => { + const data = activity.value?.data; + if (data) { + const title = data.ticketTitle; + const priority = data.priority; + + // Send confirmation to the conversation + await send(`Ticket created: **${title}** [${priority}]`); + } + return { status: 200, body: {} }; +}); + +app.start(3978); +``` + +**Manifest for the message action extension:** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "createTicketFromMessage", + "type": "action", + "title": "Create Ticket", + "description": "Create a ticket from this message", + "context": ["message"], + "fetchTask": true + } + ] + } + ] +} +``` + +### Global shortcut → compose extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Global shortcut — appears in the ⚡ menu +app.shortcut("quick_note", async ({ ack, shortcut, client }) => { + await ack(); + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: "modal", + callback_id: "quick_note_modal", + title: { type: "plain_text", text: "Quick Note" }, + submit: { type: "plain_text", text: "Save" }, + blocks: [ + { + type: "input", + block_id: "note_block", + label: { type: "plain_text", text: "Note" }, + element: { + type: "plain_text_input", + action_id: "note_input", + multiline: true, + }, + }, + ], + }, + }); +}); + +app.view("quick_note_modal", async ({ ack, view }) => { + const note = view.state.values.note_block.note_input.value!; + await ack(); + await saveNote(note); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message.ext.open" as any, async ({ activity }) => { + if (activity.value?.commandId === "quickNote") { + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Quick Note", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [{ + type: "Input.Text", + id: "noteText", + label: "Note", + isMultiline: true, + isRequired: true, + }], + actions: [{ type: "Action.Submit", title: "Save" }], + }, + }, + }, + }, + }, + }; + } +}); + +app.on("message.ext.submit" as any, async ({ activity }) => { + const note = activity.value?.data?.noteText; + if (note) { + await saveNote(note); + } + // Return empty to close the task module + return { status: 200, body: {} }; +}); + +async function saveNote(note: string) { /* persist note */ } + +app.start(3978); +``` + +**Manifest for compose/commandBox action:** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "quickNote", + "type": "action", + "title": "Quick Note", + "description": "Save a quick note", + "context": ["compose", "commandBox"], + "fetchTask": true + } + ] + } + ] +} +``` + +### Shortcut mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.shortcut('callback_id')` (global) | `message.ext.open` + `commandId` check | Compose extension action | +| `app.shortcut('callback_id')` (message) | `message.ext.open` + `commandId` check | `context: ['message']` in manifest | +| `shortcut.trigger_id` + `views.open()` | `fetchTask: true` → return task module | No trigger_id needed | +| `shortcut.message` | `activity.value.messagePayload` | Target message content | +| `callback_id` routing | `activity.value.commandId` routing | Different field name | +| `view_submission` handler | `message.ext.submit` handler | Form data in `activity.value.data` | +| `ack()` + background work | Return minimal card + async work | No fire-and-forget | +| `say()` / `respond()` after shortcut | `send()` or return compose card | Can insert into compose box | + +## pitfalls + +- **Missing `composeExtensions` commands in manifest**: Each shortcut must have a corresponding command entry in the manifest with `type: "action"`. Without it, the action never appears in Teams' UI. +- **Forgetting `fetchTask: true`**: Without this flag, Teams won't invoke the `message.ext.open` handler. Instead, it expects parameters defined in the manifest and skips the task module entirely. +- **`context` array determines placement**: Omitting the `context` array or using wrong values means the action appears in unexpected places or not at all. Use `['message']` for message shortcuts, `['compose', 'commandBox']` for global shortcuts. +- **`messagePayload` HTML content**: The target message body in `activity.value.messagePayload.body.content` may be HTML-formatted (not plain text). Parse or strip HTML before using as form default values. +- **No background-only shortcuts**: Slack allows shortcuts that just `ack()` and do work silently. Teams action extensions always present a task module. Wrap background actions in a minimal "Processing..." → "Done" card flow. +- **Submit handler must return within 3 seconds**: Like all invoke activities, the `message.ext.submit` handler must respond quickly. Long-running operations should return immediately and process asynchronously. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/create-task-module +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionscommands +- https://github.com/microsoft/teams.ts +- https://api.slack.com/interactivity/shortcuts — Slack shortcuts +- https://api.slack.com/reference/interaction-payloads/shortcuts — Slack shortcut payloads + +## instructions + +Use this expert when adding cross-platform support in either direction for shortcuts and message/compose extensions. It covers: Slack global shortcuts bridged to Teams compose extensions, Slack message shortcuts bridged to Teams action-based extensions with `context: ['message']`, `trigger_id` vs `fetchTask: true`, target message access via `messagePayload`, task module form flows bridged to Slack modals, and reverse mapping from Teams extensions to Slack shortcuts. Pair with `../teams/ui.message-extensions-ts.md` for general message extension patterns, `../teams/ui.dialogs-task-modules-ts.md` for task module details, and `ui-modals-dialogs-ts.md` for modal-to-dialog conversion. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack shortcuts (global shortcuts and message shortcuts) and Microsoft Teams action-based message extensions in either direction. Cover: manifest composeExtensions command config with context arrays, fetchTask: true for task module invocation, trigger_id elimination, message payload access for message shortcuts, view_submission to message.ext.submit, the lack of fire-and-forget shortcuts in Teams, compose box card insertion, and reverse mapping from Teams compose/action extensions back to Slack shortcuts. Include TypeScript code examples and a mapping table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/transport-socketmode-https-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/transport-socketmode-https-ts.md new file mode 100644 index 000000000..60334779b --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/transport-socketmode-https-ts.md @@ -0,0 +1,231 @@ +# transport-socketmode-https-ts + +## purpose + +Bridges Slack transport (Socket Mode, HTTP Events API) and Teams Bot Framework HTTPS transport for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack has 3 transport modes; Teams has 1.** Slack supports HTTP webhooks (Events API), Socket Mode (WebSocket for firewalled environments), and RTM (legacy WebSocket). Teams uses exclusively HTTPS via the Azure Bot Framework Service channel. All three Slack transports collapse into one Teams model. [learn.microsoft.com -- Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview) +2. **Slack Socket Mode (`@slack/socket-mode`) has NO Teams equivalent.** Socket Mode exists because Slack apps behind firewalls can't receive inbound HTTP. In Teams, the bot MUST expose a public HTTPS endpoint. Use Azure App Service, ngrok (dev), or Azure Dev Tunnels for connectivity. For strict on-premises environments that truly cannot expose any endpoint, Azure Relay provides a hybrid connection where the bot connects outbound to Azure, and Azure proxies inbound Teams traffic through that connection. This adds 10–50ms latency but requires zero inbound firewall rules. [learn.microsoft.com -- Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) +3. **Slack's `xapp-` token for Socket Mode → not needed.** Socket Mode uses a special app-level token. Teams uses `CLIENT_ID`/`CLIENT_SECRET`/`TENANT_ID` for all communication. Remove all `SLACK_APP_TOKEN` references. +4. **The WebSocket connection lifecycle disappears.** Slack Socket Mode manages a persistent WebSocket: connect, reconnect on failure, handle `disconnect` events, manage `envelope_id` acknowledgements. In Teams, the Bot Framework sends HTTP POST requests to your endpoint — no connection management needed. +5. **Slack Socket Mode envelope acknowledgement → not needed.** In Socket Mode, each event arrives in an envelope with an `envelope_id` that must be acknowledged within 3 seconds. In Teams, the HTTP response itself IS the acknowledgement — the Bot Framework sends a POST, your server returns 200. +6. **Slack RTM API is fully deprecated — do not port.** If the source project uses RTM (`rtm.start`, `rtm.connect`), it's already legacy. Convert directly to Teams HTTPS handlers without attempting to map RTM patterns. +7. **Teams' deployment model requires a public HTTPS endpoint.** Unlike Socket Mode (outbound-only), Teams bots receive inbound HTTPS from the Bot Framework Service. This means: (a) you need a domain/IP, (b) you need TLS, (c) you need the endpoint registered in the Azure Bot resource. +8. **Slack's retry mechanism (`x-slack-retry-num` header, `x-slack-retry-reason`)** is replaced by Bot Framework delivery guarantees. Teams does not retry failed deliveries in the same way — if your endpoint is down, activities may be lost. Ensure high availability. +9. **Java SDK's `SocketModeClient` classes (`SocketModeApp`, `SocketModeClient`, `JavaxWebSocketClient`, `TyrusWebSocketClient`)** are entirely eliminated. Delete all Socket Mode client code, connection management, reconnection logic, and WebSocket libraries. +10. **Slack's event subscription URL verification challenge (`url_verification` event)** has no Teams equivalent. Teams verifies your endpoint via the Bot Framework registration in Azure Portal, not via an HTTP challenge. Remove all challenge-response code. +11. **Transport is inherently asymmetric.** Slack supports both Socket Mode (outbound WebSocket) and HTTP (inbound webhooks), while Teams requires HTTPS exclusively. For Teams → Slack, adding Socket Mode is optional but useful for firewall-restricted environments. A cross-platform bot typically uses HTTP/HTTPS for both platforms, with Socket Mode as an optional Slack-only enhancement. +12. **Add a health check endpoint for production hosting.** Azure App Service, Container Apps, and Kubernetes all use HTTP health probes to determine if the app is alive. Expose `GET /api/health` returning 200 with a JSON body. Configure the probe path in your hosting platform so failed health checks trigger automatic restarts instead of silent failures. [learn.microsoft.com -- Health checks](https://learn.microsoft.com/en-us/azure/app-service/monitor-instances-health-check) + +## patterns + +### Slack Socket Mode → Teams HTTPS endpoint + +**Slack Socket Mode (before):** + +```typescript +// --- Slack with Socket Mode --- +import { App } from '@slack/bolt'; +import { SocketModeReceiver } from '@slack/bolt'; + +// Socket Mode: outbound WebSocket, no public endpoint needed +const receiver = new SocketModeReceiver({ + appToken: process.env.SLACK_APP_TOKEN!, // xapp-... token + // Manages WebSocket connection lifecycle internally: + // - Connects to wss://wss-primary.slack.com + // - Handles reconnection on disconnect + // - Acknowledges each envelope_id within 3 seconds +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver, // Uses Socket Mode instead of HTTP +}); + +app.message(/hello/i, async ({ say }) => { + await say('Hello via Socket Mode!'); +}); + +await app.start(); +console.log('Connected via WebSocket (no public URL needed)'); +``` + +**Teams (after):** + +```typescript +// --- Teams with HTTPS endpoint --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; +import { DevtoolsPlugin } from '@microsoft/teams.dev'; + +// Teams: inbound HTTPS, public endpoint required +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), + plugins: [new DevtoolsPlugin()], + // No socket mode, no WebSocket, no app-level token + // Bot Framework sends HTTPS POST to your /api/messages endpoint +}); + +app.message(/hello/i, async ({ send }) => { + await send('Hello via HTTPS!'); +}); + +app.start(3978); +// Requires public HTTPS endpoint: +// - Dev: ngrok http 3978 or Azure Dev Tunnels +// - Prod: Azure App Service with custom domain + TLS +``` + +### Java SDK Socket Mode classes → DELETE + +**Java (before):** + +```java +// --- Java Socket Mode setup (DELETE ALL OF THIS) --- +import com.slack.api.bolt.App; +import com.slack.api.bolt.socket_mode.SocketModeApp; + +// WebSocket client selection +import com.slack.api.socket_mode.SocketModeClient; +import javax.websocket.WebSocketContainer; + +App app = new App(AppConfig.builder() + .singleTeamBotToken(System.getenv("SLACK_BOT_TOKEN")) + .build()); + +app.event(MessageEvent.class, (req, ctx) -> { + ctx.say("Hello!"); + return ctx.ack(); +}); + +// Socket Mode wrapper — manages WebSocket connection lifecycle +SocketModeApp socketModeApp = new SocketModeApp( + System.getenv("SLACK_APP_TOKEN"), // xapp-... token + app // wraps the Bolt app +); +socketModeApp.start(); // connects via WebSocket + +// Internally manages: +// - WebSocket connection to Slack +// - Automatic reconnection +// - Envelope ID acknowledgement +// - Multiple client backends (Tyrus, Java-WebSocket) +``` + +**Teams TypeScript (after):** + +```typescript +// --- Teams: everything above is replaced by this --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), +}); + +app.on('message', async ({ send }) => { + await send('Hello!'); +}); + +app.start(3978); +// No SocketModeApp, no WebSocket client, no app-level token +// Delete: SocketModeApp, SocketModeClient, javax.websocket imports, +// Tyrus/Java-WebSocket dependencies, xapp token config +``` + +### Transport comparison table + +| Aspect | Slack HTTP (Events API) | Slack Socket Mode | Teams Bot Framework | +|---|---|---|---| +| Direction | Inbound HTTP POST | Outbound WebSocket | Inbound HTTPS POST | +| Public endpoint | Required | Not required | Required | +| TLS | Required | N/A (outbound) | Required | +| Authentication | Signing secret HMAC | App-level token | Bot Framework JWT (auto) | +| Event delivery | HTTP POST per event | WebSocket frames | HTTPS POST per activity | +| Acknowledgement | Return HTTP 200 in 3s | Send envelope_id ack | Return HTTP 200 | +| Retry on failure | Yes (`x-slack-retry-*`) | Reconnect WebSocket | Limited retries | +| Connection mgmt | Stateless | Client manages WS | Stateless | +| Firewall-friendly | No (needs inbound) | Yes (outbound only) | No (needs inbound) | +| Dev tunneling | ngrok / localtunnel | Not needed | ngrok / Dev Tunnels | + +### Health check endpoint pattern + +```typescript +import express from 'express'; + +const webApp = express(); + +// Health check for Azure App Service / Container Apps probes +webApp.get('/api/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }); +}); + +// The Teams app uses the same Express server (or integrate with app.start()) +webApp.listen(process.env.PORT || 3978, () => { + console.log(`Bot running on port ${process.env.PORT || 3978}`); +}); +``` + +### Production deployment stages + +| Stage | Hosting | Endpoint | Notes | +|---|---|---|---| +| **Local dev** | `localhost:3978` + Dev Tunnel | `https://.devtunnels.ms/api/messages` | Free; tunnels expire after idle timeout | +| **Staging** | Azure App Service (B1) | `https://my-bot-staging.azurewebsites.net/api/messages` | Use deployment slots; Always On enabled | +| **Production** | Azure App Service (S1+) or Container Apps | `https://my-bot.azurewebsites.net/api/messages` | Custom domain + managed TLS; health check configured | + +### Environment variable cleanup + +| Slack Variable | Action | Why | +|---|---|---| +| `SLACK_APP_TOKEN` (`xapp-...`) | Delete | Socket Mode only | +| `SLACK_BOT_TOKEN` (`xoxb-...`) | Replace with `CLIENT_ID`+`CLIENT_SECRET` | Different auth model | +| `SLACK_SIGNING_SECRET` | Delete | Bot Framework JWT is auto | +| `SLACK_CLIENT_ID` | Replace with `CLIENT_ID` | Azure Bot app ID | +| `SLACK_CLIENT_SECRET` | Replace with `CLIENT_SECRET` | Azure Bot secret | +| *(add new)* | `TENANT_ID` | Azure AD tenant | + +## pitfalls + +- **Trying to use WebSockets with Teams**: Teams bots use HTTPS, not WebSocket. The Bot Framework Service sends activities as HTTP POST requests. Do not attempt to create a WebSocket server for Teams. +- **Forgetting the public endpoint requirement**: Socket Mode works behind firewalls with no public URL. Teams bots MUST have a public HTTPS endpoint. In development, use `ngrok http 3978` or Azure Dev Tunnels. In production, use Azure App Service. +- **Porting reconnection logic**: Socket Mode clients implement complex reconnection (backoff, failover). Delete all reconnection code — HTTPS is stateless, there's nothing to reconnect. +- **Porting envelope acknowledgement**: Socket Mode requires acknowledging each event's `envelope_id`. Teams has no envelope concept — the HTTP 200 response IS the acknowledgement. Remove all envelope handling. +- **Slack's URL verification challenge**: Slack's Events API sends a `url_verification` challenge to verify your endpoint. Teams doesn't do this — endpoint verification happens during Azure Bot registration. Delete challenge handlers. +- **RTM API patterns**: If the source uses RTM (`rtm.connect`, `rtm.start`), these are completely obsolete even in Slack. Do not attempt to map RTM patterns — convert directly to Teams HTTPS handlers. +- **Missing TLS in production**: Teams requires HTTPS. Azure App Service provides TLS automatically. If self-hosting, you must configure TLS certificates. +- **Assuming event delivery retries**: Slack retries failed HTTP deliveries (with `x-slack-retry-num`). Bot Framework has limited retry guarantees. Design for idempotency but don't depend on retries. +- **Dev tunnels expire**: Azure Dev Tunnels and ngrok free-tier URLs expire after idle timeouts or session restarts. The Bot Framework registration must be updated with the new URL each time. Use a persistent tunnel ID or switch to Azure-hosted staging for stable endpoints. +- **No health check = blind restarts**: Without a health check endpoint, Azure App Service cannot distinguish between a crashed app and a slow response. The platform may restart a healthy but busy instance, or leave a crashed instance running. Always configure `/api/health` and set the health check path in the hosting platform. + +## references + +- https://api.slack.com/apis/connections/socket -- Slack Socket Mode documentation +- https://api.slack.com/apis/connections/events-api -- Slack Events API (HTTP) +- https://api.slack.com/rtm -- Slack RTM API (deprecated) +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview -- Bot Framework architecture +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication -- Bot Framework authentication +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/debug/locally-with-an-ide -- Local dev with tunneling +- https://github.com/microsoft/teams.ts -- Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack transport (Socket Mode, HTTP Events API) or Teams Bot Framework HTTPS transport. The core message: **all three Slack transports collapse into one Teams model (inbound HTTPS)**. Transport is inherently asymmetric -- Slack supports both Socket Mode and HTTP, while Teams requires HTTPS. For Teams → Slack, adding Socket Mode is optional but useful for firewall-restricted environments. Focus on: (1) understanding transport differences between platforms, (2) envelope acknowledgement vs HTTP response patterns, (3) setting up the HTTPS endpoint with proper TLS, (4) configuring Azure Bot registration. Pair with `events-activities-ts.md` for event/activity mapping once the transport layer is resolved, and `../teams/runtime.app-init-ts.md` for Teams app initialization. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack transport (Socket Mode WebSocket, HTTP Events API) and Teams Bot Framework HTTPS transport in either direction for cross-platform bots. Cover: why all three Slack transports collapse into one Teams model, transport asymmetry (Socket Mode is Slack-only), Socket Mode as optional enhancement for firewall-restricted environments, public HTTPS endpoint requirement, Bot Framework JWT authentication, deployment options (Azure App Service, ngrok, Dev Tunnels), environment variable cleanup, and transport comparison table." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/ui-app-home-personal-tab-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-app-home-personal-tab-ts.md new file mode 100644 index 000000000..ade938a88 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-app-home-personal-tab-ts.md @@ -0,0 +1,318 @@ +# ui-app-home-personal-tab-ts + +## purpose + +Bridges Slack App Home and Teams personal tab / bot welcome card for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack's App Home is a dedicated per-user tab in the Slack app sidebar, rendered by the bot via `views.publish`. Teams has no direct equivalent of a bot-rendered home tab. The closest alternatives are: (a) a personal bot conversation with a welcome Adaptive Card, (b) a static personal tab (web page in iframe), or (c) a bot-powered tab using `tab.fetch`/`tab.submit` handlers. +2. Slack `app.event(AppHomeOpenedEvent)` fires when a user navigates to the bot's Home tab. In Teams, the equivalent trigger for a personal bot conversation is `app.on('install.add')` (first install) or `app.on('conversationUpdate')` with `membersAdded` (bot added to 1:1 chat). There is no "user opened the chat" event — the bot sends its home card proactively at install time. +3. Slack `views.publish(user_id, view)` publishes a view to a specific user's Home tab. In Teams, send an Adaptive Card to the user's 1:1 conversation using `send()` in the install handler or via proactive messaging. The card serves as the "home" experience. +4. Slack's Home tab Block Kit JSON maps to an Adaptive Card. Convert using the block-kit-to-adaptive-cards mapping table. The card replaces the full home view — use `Container` and `ColumnSet` for layout density. +5. Slack Home tab dynamic updates (re-calling `views.publish` with new content) map to sending a new card or updating the existing card via `updateActivity` in Teams. Store the original activity ID to update it later. +6. Slack's `view.hash` for race condition protection (only update if the hash matches) has no Teams equivalent. Teams card updates via `updateActivity` always overwrite. If concurrent updates are a concern, implement application-level versioning in the card's `Action.Submit.data`. +7. For a richer home experience equivalent to Slack's App Home, consider a **static tab** — a web page declared in the Teams manifest (`staticTabs` array) that loads in an iframe. This supports full HTML/JS and is closer to Slack's App Home flexibility, but requires hosting a web page. +8. Teams SDK v2 supports `tab.fetch` and `tab.submit` handlers for Adaptive Card-based tabs (no iframe needed). The bot returns an Adaptive Card in response to `tab.fetch`, and handles form submissions via `tab.submit`. This is the closest behavioral match to Slack's `views.publish` pattern. +9. When migrating App Home with action buttons, remember that Slack Home tab actions fire `blockAction` events. In Teams, Adaptive Card buttons in 1:1 chat fire `adaptiveCards.actionSubmit` handlers. The routing mechanism changes but the concept is the same. +10. Slack App Home can show different content per user based on `event.user`. In Teams 1:1 chat, the bot always talks to one user, so personalization is inherent. For tab-based approaches, use `tab.fetch` which receives user context in the activity. +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish` for dynamic content. The Adaptive Card tab content maps to Block Kit views. `tab.submit` actions map to `view_submission` or `block_actions` events. The `install.add` welcome card maps to a `views.publish` call triggered by `app_home_opened`. + +## patterns + +### Option A: Welcome card on install (simplest) + +**Slack (before):** + +```kotlin +app.event(AppHomeOpenedEvent::class.java) { e, ctx -> + val res = ctx.client().viewsPublish { + it.userId(e.event.user) + .viewAsString(homeViewJson) + .hash(e.event.view?.hash) + } + ctx.ack() +} +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('home-bot'), +}); + +// Send a "home" card when the bot is installed (replaces AppHomeOpenedEvent) +app.on('install.add', async ({ send }) => { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Welcome to the App!', + size: 'Large', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: `Last updated: ${new Date().toISOString()}`, + isSubtle: true, + wrap: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Action A', + data: { verb: 'actionA' }, + }, + { + type: 'Action.Submit', + title: 'Action B', + data: { verb: 'actionB' }, + }, + ], + }, + }], + }); +}); + +// Handle button clicks on the home card +app.on('adaptiveCards.actionSubmit' as any, async ({ activity, send }) => { + const verb = activity.value?.verb; + if (verb === 'actionA') { + await send('You clicked Action A!'); + } else if (verb === 'actionB') { + await send('You clicked Action B!'); + } +}); + +app.start(3978); +``` + +### Option B: Adaptive Card tab (closest to App Home) + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('tab-bot'), +}); + +// tab.fetch replaces AppHomeOpenedEvent — fires when user opens the tab +app.on('tab.fetch' as any, async ({ activity }) => { + const userId = activity.from?.id; + return { + status: 200, + body: { + tab: { + type: 'continue', + value: { + cards: [{ + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Home', + size: 'Large', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: `Hello, user ${userId}! Updated: ${new Date().toISOString()}`, + wrap: true, + }, + { + type: 'ActionSet', + actions: [ + { + type: 'Action.Submit', + title: 'Refresh', + data: { verb: 'refresh' }, + }, + ], + }, + ], + }, + }, + }], + }, + }, + }, + }; +}); + +// tab.submit handles actions within the tab +app.on('tab.submit' as any, async ({ activity }) => { + const verb = activity.value?.data?.verb; + if (verb === 'refresh') { + // Return updated tab content + return { + status: 200, + body: { + tab: { + type: 'continue', + value: { + cards: [{ + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [{ + type: 'TextBlock', + text: `Refreshed at ${new Date().toISOString()}`, + }], + }, + }, + }], + }, + }, + }, + }; + } + return { status: 200, body: {} }; +}); + +app.start(3978); +``` + +### Option C: Static tab with hosted web page (most flexible) + +**Manifest `staticTabs` entry:** + +```json +{ + "staticTabs": [ + { + "entityId": "homeTab", + "name": "Home", + "contentUrl": "https://your-app.azurewebsites.net/tab/home", + "scopes": ["personal"] + } + ], + "validDomains": [ + "your-app.azurewebsites.net" + ] +} +``` + +**Express route serving the tab page:** + +```typescript +import express from 'express'; +import path from 'path'; + +const webApp = express(); + +// Serve static assets +webApp.use('/tab/assets', express.static(path.join(__dirname, 'public'))); + +// Tab page route — returns HTML that initializes the Teams JS SDK +webApp.get('/tab/home', (req, res) => { + res.send(` + + + + Home + + + + +
Loading...
+ + +`); +}); + +// API endpoint for tab actions +webApp.post('/tab/api/action', express.json(), (req, res) => { + const { verb } = req.body; + res.json({ status: 'ok', verb, processedAt: new Date().toISOString() }); +}); + +webApp.listen(3000, () => console.log('Tab server on :3000')); +``` + +### Bridging decision table + +| Slack App Home Feature | Option A: 1:1 Welcome Card | Option B: Adaptive Card Tab | Option C: Static Tab (iframe) | +|---|---|---|---| +| Trigger on open | `install.add` (once) | `tab.fetch` (every open) | Page load | +| Dynamic content | Proactive message update | Return new card on each fetch | Full web app | +| User actions | `actionSubmit` handlers | `tab.submit` handlers | Web forms/JS | +| Complexity | Low | Medium | High | +| Manifest changes | None | `staticTabs` with `contentBotId` | `staticTabs` with `contentUrl` | +| Best for | Simple welcome/info | Dashboard-like home tabs | Rich interactive UIs | + +## pitfalls + +- **No "opened" event in 1:1 chat**: Slack fires `AppHomeOpenedEvent` every time the user navigates to the Home tab. Teams has no equivalent for 1:1 bot chat. The bot is notified when installed, not when the user opens the chat. Use `tab.fetch` (Option B) if you need an on-open trigger. +- **views.publish is proactive**: Slack's `views.publish` can be called anytime to update the Home tab for any user. In Teams, updating a 1:1 message requires a stored conversation reference and the original activity ID. Set up proactive messaging infrastructure if you need background updates. +- **Race condition protection gone**: Slack's `view.hash` prevents concurrent updates from clobbering each other. Teams has no equivalent. If multiple processes might update the same card, implement optimistic locking in your application layer. +- **Block Kit → Adaptive Card**: The home view's Block Kit JSON must be converted to an Adaptive Card. The Home tab often uses `actions` blocks with buttons — these become `Action.Submit` buttons in the Adaptive Card. See `ui-block-kit-adaptive-cards-ts.md` for the full mapping. +- **Manifest required for tabs**: Options B and C require a `staticTabs` entry in the Teams manifest. Option A (1:1 chat) does not require manifest changes beyond the base bot registration. +- **Tab card size limits**: Adaptive Card tabs are subject to the same 28 KB card size limit. If the Slack Home tab rendered long lists, paginate or load data on demand. +- **Static tab requires a hosted web page**: Option C (static tab) requires deploying and hosting a web page accessible via HTTPS. This is a separate hosting concern from the bot itself. Use the same Azure App Service or add a route to your existing Express server. +- **`validDomains` must include the tab host**: If the `contentUrl` domain is not listed in the manifest's `validDomains` array, Teams will refuse to load the tab with a blank iframe. This is the most common static tab deployment failure. +- **Teams JS SDK initialization is mandatory**: Every tab page must call `microsoftTeams.app.initialize()` before accessing any Teams context. Without it, the tab loads but `getContext()` returns nothing and deep links fail. The SDK script must be loaded from the official CDN or npm package. + +## references + +- https://api.slack.com/surfaces/app-home — Slack App Home documentation +- https://api.slack.com/events/app_home_opened — AppHomeOpenedEvent reference +- https://api.slack.com/methods/views.publish — views.publish API +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs — Teams tabs overview +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/how-to/create-personal-tab — Personal tabs +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages — Proactive messaging +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack App Home or Teams personal tab / bot welcome card. It covers three bridging paths: (A) a welcome Adaptive Card in 1:1 bot chat (simplest), (B) an Adaptive Card-based tab using `tab.fetch`/`tab.submit` (closest to App Home behavior), and (C) a static web tab in an iframe (most flexible). For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish` for dynamic content. The decision table helps choose the right approach based on requirements. Pair with `ui-block-kit-adaptive-cards-ts.md` for converting between Block Kit and Adaptive Cards, `../teams/ui.adaptive-cards-ts.md` for card construction, and `../teams/runtime.proactive-messaging-ts.md` for background card updates. + +## research + +Deep Research prompt: + +"Write a micro expert on bridging Slack App Home (AppHomeOpenedEvent, views.publish, dynamic home tab with Block Kit) and Microsoft Teams personal tab / bot welcome card in either direction. Cover three approaches: 1:1 bot welcome card, Adaptive Card-based tabs (tab.fetch/tab.submit), and static tabs (iframe). Include reverse-direction notes for Teams → Slack mapping, a decision matrix, side-by-side code examples, and pitfalls around proactive messaging, race conditions, and manifest configuration." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/ui-block-kit-adaptive-cards-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-block-kit-adaptive-cards-ts.md new file mode 100644 index 000000000..c528940f9 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-block-kit-adaptive-cards-ts.md @@ -0,0 +1,453 @@ +# ui-block-kit-adaptive-cards-ts + +## purpose + +Bridges Slack Block Kit and Teams Adaptive Card UI structures for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Target Adaptive Cards schema version `1.5` for Teams desktop/mobile compatibility (learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format). +2. Every Slack `action_id` must become a key inside the Adaptive Card `Action.Submit.data` object so the bot can route by `data.action` (adaptivecards.io/explorer/Action.Submit.html). +3. Slack `block_id` has no direct equivalent -- encode it in `Action.Submit.data.blockId` if you need round-trip tracing. +4. Slack mrkdwn uses `*bold*` and `_italic_`; Adaptive Cards use standard Markdown (`**bold**`, `_italic_`) inside `TextBlock.text` with `"style": "default"` (adaptivecards.io/explorer/TextBlock.html). +5. Slack `image_url` fields map to `Image.url`; always set `Image.altText` (required for accessibility in Teams). +6. Slack modals (`views.open` / `views.push`) map to Teams task modules invoked via `task/fetch` and rendered with an Adaptive Card; submission maps to `task/submit` (learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots). +7. Slack `view_submission` payload fields map: `view.state.values[block_id][action_id].value` becomes the flat `data` object returned by `task/submit`, keyed by each input's `id`. +8. Slack `static_select` maps to `Input.ChoiceSet` with `"style": "compact"`. Slack `multi_static_select` maps to `Input.ChoiceSet` with `"isMultiSelect": true` (adaptivecards.io/explorer/Input.ChoiceSet.html). +9. Slack `overflow` menu has no Adaptive Card equivalent -- redesign as `ActionSet` with multiple `Action.Submit` buttons or a single `Input.ChoiceSet` dropdown. +10. Teams Adaptive Cards support `ColumnSet`/`Column` and `FactSet` which have no Block Kit equivalent -- use them to improve layout density during migration. + +## strategy + +The core principle: **map the blocks mechanically, then redesign the layout and interaction model to be native Teams rather than ported Slack.** A 1:1 block-to-element swap produces a functional card that looks like Slack awkwardly wearing a Teams suit. Follow these four phases in order. + +### Phase 1: Map for correctness + +Get every block producing the correct output using the mapping table below. This is mechanical work: +- `header` → `TextBlock` Large/Bolder +- `section` fields → `FactSet` +- `button` → `Action.Submit` with `data.verb` +- Convert `*bold*` mrkdwn to `**bold**` standard Markdown +- Replace `:emoji_shortcodes:` with Unicode equivalents (Teams does not render Slack shortcodes) +- Replace `<@U12345>` mentions with display names (Slack mention syntax does not work in Adaptive Cards) +- Swap button styles: `"primary"` → `"positive"`, `"danger"` → `"destructive"` +- Add explicit submit buttons wherever Slack had instant-fire selects + +### Phase 2: Upgrade the layout + +Once correct, leverage Adaptive Card strengths that have no Block Kit equivalent: +- Replace flat block lists with `ColumnSet`/`Container` for denser, structured layouts +- Use semantic container styles (`"attention"` = red, `"good"` = green, `"warning"` = yellow) instead of faking status with emoji +- Add client-side validation (`isRequired`, `errorMessage`, `regex`, `min`/`max`) instead of relying entirely on server-side checks +- Use `Input.ChoiceSet` with `"style": "filtered"` for typeahead search to replace `external_select` server-side handlers +- Use `FactSet` for clean key/value pairs instead of manual mrkdwn formatting in `section.fields` + +### Phase 3: Rethink the interaction model + +This is the biggest behavioral shift. Slack's model is **event-per-interaction** -- every select and button fires immediately. Teams' model is **form-then-submit**. +- Group related inputs together and submit them as a batch with a single `Action.Submit` +- Accept fewer round trips -- the UX feels different, so lean into it rather than fighting it +- Use `Action.Execute` with the card `refresh` property if you genuinely need per-interaction updates or per-user card views +- Slack ephemeral messages for per-user content → Universal Actions (`Action.Execute`) for per-user card states from the same message + +### Phase 4: Handle what doesn't convert + +Have an explicit plan for each gap: +- `overflow` menu → redesign as `Input.ChoiceSet` dropdown or an `ActionSet` with multiple buttons +- Stacked modals (`views.push`) → flatten into multi-step cards or sequential task modules (Teams task modules do not stack) +- `dispatch_action` live updates → accept the batch-submit model, or use `Action.Execute` refresh for critical cases +- `private_metadata` → embed hidden state in `Action.Submit.data` fields or use bot conversation state +- `view_submission` with field-level errors keeping the modal open → no equivalent in Teams; validate client-side with `isRequired`/`regex`, or close the task module and send an error message +- Action count overflow (Slack allows 25 per block, Teams allows 6 per `ActionSet`) → paginate into multiple cards or consolidate into dropdowns + +## patterns + +### mapping-table + +| Slack Block Kit | Adaptive Card Element | Notes | +|---------------------------|-------------------------------|----------------------------------------------------| +| `section` (text) | `TextBlock` | Set `wrap: true`; convert mrkdwn to standard MD | +| `section` (text+accessory)| `ColumnSet` with 2 `Column`s | Col 1 = TextBlock, Col 2 = accessory element | +| `section` (fields) | `FactSet` | Each field becomes a `Fact { title, value }` | +| `actions` | `ActionSet` | Contains `Action.Submit` / `Action.OpenUrl` | +| `divider` | `TextBlock` with `separator` | `{ "type": "TextBlock", "text": " ", "separator": true }` | +| `header` | `TextBlock` size `Large` | `{ "type": "TextBlock", "size": "Large", "weight": "Bolder" }` | +| `image` | `Image` | Set `url`, `altText`, optional `size` | +| `context` | `TextBlock` size `Small` | `{ "type": "TextBlock", "size": "Small", "isSubtle": true }` | +| `input` (plain_text) | `Input.Text` | `id` = action_id, `label` maps to `Input.Text.label` | +| `input` (static_select) | `Input.ChoiceSet` | `style: "compact"` for dropdown | +| `input` (multi_select) | `Input.ChoiceSet` multiSelect | `"isMultiSelect": true` | +| `input` (datepicker) | `Input.Date` | Format: `YYYY-MM-DD` | +| `input` (timepicker) | `Input.Time` | Format: `HH:mm` | +| `input` (checkboxes) | `Input.ChoiceSet` expanded | `"style": "expanded", "isMultiSelect": true` | +| `input` (radio_buttons) | `Input.ChoiceSet` expanded | `"style": "expanded", "isMultiSelect": false` | +| `rich_text` | `TextBlock` + `RichTextBlock` | RichTextBlock available in schema 1.5+ | + +### actions-mapping + +| Slack Element | Adaptive Card Action | Key Differences | +|----------------------|----------------------------|----------------------------------------------------| +| `button` | `Action.Submit` | `value` moves into `data`; `style: "danger"` maps to `style: "destructive"` | +| `button` (url) | `Action.OpenUrl` | `url` field is identical | +| `overflow` | *No equivalent* | Redesign as `ActionSet` or `Input.ChoiceSet` | +| `static_select` | `Input.ChoiceSet` + Submit | Slack fires on select; Teams needs explicit submit | +| `external_select` | `Input.ChoiceSet` + `Action.Submit` with `data.query` | Implement typeahead via `Input.ChoiceSet` with `"style": "filtered"` (schema 1.5) | +| `multi_static_select`| `Input.ChoiceSet` multi | Teams returns comma-separated string of values | + +### reverse-direction (Teams → Slack) + +For Teams → Slack, reverse the mapping table. Adaptive Card elements map back to Block Kit blocks: +- `TextBlock` Large/Bolder → `header` +- `FactSet` → `section` with `fields` +- `Action.Submit` with `data.verb` → `button` with `value` +- Convert `**bold**` standard Markdown to `*bold*` mrkdwn +- Replace Unicode emoji with `:emoji_shortcodes:` where Slack supports them +- Swap button styles: `"positive"` → `"primary"`, `"destructive"` → `"danger"` +- `ColumnSet`/`Container` layouts → flatten to linear `section` blocks (Block Kit has no grid) +- `Input.ChoiceSet` with `style: "filtered"` → `external_select` with server-side options handler +- `Input.ChoiceSet` + `Action.Submit` → `static_select` in `actions` block (fires immediately on select) +- `Action.Execute` per-user refresh → ephemeral messages for per-user content +- Client-side validation (`isRequired`, `regex`) → server-side validation in `view_submission` handler +- Semantic container styles (`"attention"`, `"good"`) → emoji-based status indicators or colored attachment sidebars + +Key behavioral shift (Teams → Slack): The Adaptive Card **form-then-submit** model must be decomposed into Slack's **event-per-interaction** model. Each input that previously submitted as part of a batch may need its own `block_actions` handler if the Slack UX expects instant-fire behavior. + +### worked-example-1: button workflow + +Slack Block Kit message with approve/reject buttons converted to Adaptive Card. + +```typescript +// --- Slack Block Kit (original) --- +import type { KnownBlock } from "@slack/types"; + +const slackBlocks: KnownBlock[] = [ + { + type: "section", + block_id: "request_info", + text: { type: "mrkdwn", text: "*Expense Report #1042*\nAmount: $350.00" }, + }, + { + type: "actions", + block_id: "approval_actions", + elements: [ + { + type: "button", + action_id: "approve_expense", + text: { type: "plain_text", text: "Approve" }, + style: "primary", + value: "1042", + }, + { + type: "button", + action_id: "reject_expense", + text: { type: "plain_text", text: "Reject" }, + style: "danger", + value: "1042", + }, + ], + }, +]; + +// --- Adaptive Card (converted) --- +interface AdaptiveCard { + type: "AdaptiveCard"; + $schema: string; + version: string; + body: Record[]; + actions?: Record[]; +} + +const adaptiveCard: AdaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "**Expense Report #1042**\nAmount: $350.00", + wrap: true, + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Approve", + style: "positive", + data: { + action: "approve_expense", + blockId: "approval_actions", + value: "1042", + }, + }, + { + type: "Action.Submit", + title: "Reject", + style: "destructive", + data: { + action: "reject_expense", + blockId: "approval_actions", + value: "1042", + }, + }, + ], +}; +``` + +Handler comparison: + +```typescript +// --- Slack handler (Bolt) --- +// app.action("approve_expense", async ({ action, ack, respond }) => { +// await ack(); +// const expenseId = action.value; // "1042" +// await respond({ text: `Expense ${expenseId} approved.` }); +// }); + +// --- Teams handler (Teams AI SDK) --- +import { App, TurnState } from "@microsoft/teams-ai"; +import { CardFactory } from "botbuilder"; + +export function registerExpenseHandlers(app: App): void { + app.adaptiveCards.actionSubmit("approve_expense", async (ctx, _state, data) => { + const expenseId = (data as Record).value; // "1042" + const reply = CardFactory.adaptiveCard({ + type: "AdaptiveCard", + version: "1.5", + body: [{ type: "TextBlock", text: `Expense ${expenseId} approved.` }], + }); + await ctx.updateActivity({ + type: "message", + id: ctx.activity.replyToId, + attachments: [reply], + }); + return undefined; + }); +} +``` + +### worked-example-2: modal form + +Slack modal with text input and select converted to Teams task module with Adaptive Card form. + +```typescript +// --- Slack modal (original, opened via views.open) --- +import type { View } from "@slack/types"; + +const slackModal: View = { + type: "modal", + callback_id: "create_ticket", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Submit" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Title" }, + element: { + type: "plain_text_input", + action_id: "ticket_title", + placeholder: { type: "plain_text", text: "Enter title..." }, + }, + }, + { + type: "input", + block_id: "priority_block", + label: { type: "plain_text", text: "Priority" }, + element: { + type: "static_select", + action_id: "ticket_priority", + options: [ + { text: { type: "plain_text", text: "High" }, value: "high" }, + { text: { type: "plain_text", text: "Medium" }, value: "medium" }, + { text: { type: "plain_text", text: "Low" }, value: "low" }, + ], + }, + }, + ], +}; + +// --- Adaptive Card for Teams task module (converted) --- +const taskModuleCard = { + type: "AdaptiveCard" as const, + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Create Ticket", + size: "Large", + weight: "Bolder", + }, + { + type: "Input.Text", + id: "ticket_title", + label: "Title", + placeholder: "Enter title...", + isRequired: true, + }, + { + type: "Input.ChoiceSet", + id: "ticket_priority", + label: "Priority", + style: "compact", + isRequired: true, + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Submit", + data: { action: "create_ticket" }, + }, + ], +}; +``` + +Task module invocation and submission handler: + +```typescript +import { + TeamsActivityHandler, + TurnContext, + TaskModuleResponse, + CardFactory, +} from "botbuilder"; + +class TicketBot extends TeamsActivityHandler { + // Replaces Slack's views.open -- triggered by messaging extension or Action.Submit + async handleTeamsTaskModuleFetch( + context: TurnContext + ): Promise { + return { + task: { + type: "continue", + value: { + title: "Create Ticket", + width: "medium", + height: "medium", + card: CardFactory.adaptiveCard(taskModuleCard), + }, + }, + }; + } + + // Replaces Slack's view_submission handler + async handleTeamsTaskModuleSubmit( + context: TurnContext + ): Promise { + const formData = context.activity.value?.data as { + action: string; + ticket_title: string; + ticket_priority: string; + }; + + // Slack: view.state.values.title_block.ticket_title.value + const title = formData.ticket_title; + // Slack: view.state.values.priority_block.ticket_priority.selected_option.value + const priority = formData.ticket_priority; + + await context.sendActivity(`Ticket created: "${title}" [${priority}]`); + + // Return void to close the task module (like no response_action in Slack) + return undefined; + } +} +``` + +### Confirmation dialog pattern (Y14) + +Use `Action.ShowCard` for inline confirmation — the Teams equivalent of Slack's native `confirm` object on buttons. + +```typescript +// Slack: button with confirm dialog +const slackButton = { + type: "button", + text: { type: "plain_text", text: "Delete" }, + style: "danger", + action_id: "delete_item", + value: "42", + confirm: { + title: { type: "plain_text", text: "Are you sure?" }, + text: { type: "mrkdwn", text: "This action cannot be undone." }, + confirm: { type: "plain_text", text: "Yes, delete" }, + deny: { type: "plain_text", text: "Cancel" }, + }, +}; + +// Teams: Action.ShowCard inline confirmation +const teamsConfirmAction = { + type: "Action.ShowCard", + title: "Delete", + card: { + type: "AdaptiveCard", + body: [ + { + type: "TextBlock", + text: "Are you sure? This action cannot be undone.", + weight: "Bolder", + color: "Attention", + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Yes, delete", + style: "destructive", + data: { action: "confirm_delete", itemId: "42" }, + }, + { + type: "Action.Submit", + title: "Cancel", + data: { action: "cancel_delete" }, + }, + ], + }, +}; +``` + +**Why `Action.ShowCard`:** Expands inline without leaving the current context — closest to Slack's native `confirm` popup. No task module overhead. + +**Don't:** Open a full task module dialog for a simple yes/no confirmation. It's too heavy for the interaction. + +**Reverse (Teams → Slack):** Add a `confirm` object directly to the button element. Platform-rendered popup with zero effort. + +## pitfalls + +- **mrkdwn vs Markdown**: Slack uses `*bold*` and `~strike~`; Adaptive Cards expect `**bold**` and `~~strike~~`. Failing to convert produces literal asterisks in Teams. +- **Instant-fire selects**: Slack `static_select` inside an `actions` block fires `block_actions` immediately on selection. Adaptive Card `Input.ChoiceSet` does nothing until an `Action.Submit` is clicked -- you must add an explicit submit button. +- **Button style names differ**: Slack `"primary"` = green, `"danger"` = red. Adaptive Cards use `"positive"` and `"destructive"`. Using Slack names silently falls back to default styling. +- **Action count limit**: Teams Adaptive Cards support a maximum of 6 actions per `ActionSet`. Slack allows up to 25 elements in an `actions` block. Redesign dense action rows into paginated cards or dropdowns. +- **`overflow` menu**: No Adaptive Card equivalent exists. Replace with an `Input.ChoiceSet` dropdown or multiple `Action.Submit` buttons. +- **`multi_static_select` return format**: Slack returns `selected_options` as an array of objects. `Input.ChoiceSet` with `isMultiSelect` returns a single comma-separated string (e.g., `"a,b,c"`). Split server-side. +- **No `dispatch_action` equivalent**: Slack inputs can set `dispatch_action: true` to fire events on every keystroke. Adaptive Cards only submit on explicit `Action.Submit`. +- **Image sizing**: Slack `image` uses `alt_text` (underscore); Adaptive Card `Image` uses `altText` (camelCase). Slack fills width by default; set Adaptive Card `"size": "stretch"` to match. +- **`private_metadata`**: Slack modals carry `private_metadata` for state. In Teams task modules, embed hidden state inside `Action.Submit.data` fields or use bot conversation state. +- **Schema version**: Using features above 1.5 (e.g., `Action.Execute` for Universal Actions) requires verifying Teams client support. Stick to 1.5 for broadest compatibility. +- **Card replacement**: Slack `respond({ replace_original: true })` replaces the message. In Teams, use `context.updateActivity()` with the original activity ID, or return an `adaptiveCard/action` invoke response. + +## references + +- https://api.slack.com/reference/block-kit/blocks -- Slack Block Kit block type reference +- https://api.slack.com/reference/block-kit/block-elements -- Slack interactive element reference +- https://api.slack.com/surfaces/modals -- Slack modal (views.open) documentation +- https://adaptivecards.io/explorer/ -- Adaptive Cards schema explorer (all element types) +- https://adaptivecards.io/explorer/Action.Submit.html -- Action.Submit schema and data field +- https://adaptivecards.io/explorer/Input.ChoiceSet.html -- Input.ChoiceSet (select/multi-select) +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference -- Teams Adaptive Card support +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots -- Task modules from bots +- https://learn.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model -- Universal Actions + +## instructions + +This expert covers bridging Slack Block Kit and Teams Adaptive Card UI structures in TypeScript. Use it when adding cross-platform support in either direction: (1) bridging a Slack Bolt app to also target Teams, (2) bridging a Teams bot to also target Slack, (3) converting Block Kit JSON payloads to Adaptive Card JSON or vice versa, (4) redesigning modal workflows into task modules or task modules into modals, or (5) mapping interactive action handlers between platforms. Start with the strategy section to understand the four-phase approach (map for correctness → upgrade layout → rethink interactions → handle gaps), consult the mapping table and reverse-direction section for specific element types, and adapt the worked examples to your use case. Pair with `../slack/ui.block-kit-ts.md` for Slack Block Kit patterns, and `../teams/ui.adaptive-cards-ts.md` for Teams Adaptive Card patterns and constraints. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Block Kit and Teams Adaptive Cards bidirectionally. Include: mapping table (Block Kit blocks <-> card elements) in both directions, interactive actions mapping (action_id <-> data.action), selects/inputs mapping, modal/task-module workflow redesign in both directions, unsupported features and redesign recommendations for each platform, and 2 worked examples (a button workflow and a modal form)." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/ui-legacy-attachments-cards-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-legacy-attachments-cards-ts.md new file mode 100644 index 000000000..0c415ebda --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-legacy-attachments-cards-ts.md @@ -0,0 +1,206 @@ +# ui-legacy-attachments-cards-ts + +## purpose + +Bridges pre-Block Kit Slack legacy attachments and Teams Adaptive Cards for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack legacy attachments (`message.attachments[]`) predate Block Kit and use a flat JSON structure with `text`, `fallback`, `color`, `callback_id`, and `actions[]`. These map to a single Adaptive Card with `TextBlock` body elements and `Action.Submit` actions. +2. Slack `app.attachmentAction(callback_id)` handles button clicks on legacy attachments. In Teams, this maps to `app.on('adaptiveCards.actionSubmit')` or `app.adaptiveCards.actionSubmit(verb, handler)` where `verb` is embedded in `Action.Submit.data`. +3. Slack legacy attachment `color` (hex string like `"#3AA3E3"` or named like `"good"`, `"warning"`, `"danger"`) maps to Adaptive Card `Container` with `style` property: `"good"` → `"good"`, `"warning"` → `"warning"`, `"danger"` → `"attention"`. For custom hex colors, wrap the card content in a `Container` with `"style": "emphasis"` (no arbitrary hex colors in Adaptive Cards). +4. Slack legacy attachment `fallback` (plain-text fallback for notifications) maps to the `fallback` property on the Adaptive Card's `content` object (e.g., `{ ..., "fallback": "Fallback text for notifications" }`). Always provide this for accessibility. +5. Slack legacy attachment `actions[]` with `type: "button"` map to Adaptive Card `Action.Submit` buttons. The button `name` and `value` become keys in `Action.Submit.data`. The `callback_id` becomes the `verb` routing key. +6. Slack legacy `confirm` objects (confirmation dialogs on buttons) have no direct Adaptive Card equivalent. Redesign as: (a) an `Action.ShowCard` that reveals a confirmation sub-card with Confirm/Cancel buttons, or (b) a two-step flow where the first click sends a confirmation card and the second click executes the action. +7. Slack `attachment_type: "default"` has no Adaptive Card equivalent — it was a Slack internal marker. Remove it during migration. +8. Slack legacy attachment `actions[]` with `type: "select"` (dropdown menus) map to Adaptive Card `Input.ChoiceSet` with `style: "compact"`. Remember that Adaptive Card selects require an explicit `Action.Submit` button — they do not fire on selection like Slack. +9. Slack `respond({ replace_original: true })` (replacing the original message after an attachment action) maps to Teams `updateActivity()` with the original activity ID and a new Adaptive Card attachment. +10. Messages mixing legacy attachments AND Block Kit blocks should be bridged to a single Adaptive Card. The attachment text becomes header/body `TextBlock` elements and the Block Kit portion follows the standard block-kit-to-adaptive-cards mapping. +11. **Reverse direction (Teams → Slack):** While not recommended (Block Kit is preferred), Adaptive Cards can be mapped to legacy attachment format if targeting very old Slack integrations. Map `TextBlock` to `attachments[].text`, `Container` style to `color`, and `Action.Submit` to `actions[].type: "button"`. Prefer converting to Block Kit instead of legacy attachments for new Slack integrations. + +## patterns + +### Legacy attachment with buttons → Adaptive Card + +**Slack (before):** + +```kotlin +// --- Slack legacy attachment JSON --- +val message = """ +{ + "text": "Would you like to play a game?", + "attachments": [ + { + "text": "Choose a game to play", + "fallback": "You are unable to choose a game", + "callback_id": "wopr_game", + "color": "#3AA3E3", + "attachment_type": "default", + "actions": [ + { "name": "game", "text": "Chess", "type": "button", "value": "chess" }, + { "name": "game", "text": "Falken's Maze", "type": "button", "value": "maze" }, + { + "name": "game", + "text": "Thermonuclear War", + "style": "danger", + "type": "button", + "value": "war", + "confirm": { + "title": "Are you sure?", + "text": "Wouldn't you prefer a good game of chess?", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + } + ] +} +""" + +app.attachmentAction("wopr_game") { req, ctx -> + ctx.respond(secondMessage) + ctx.ack() +} +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('game-bot'), +}); + +// The game selection card (replaces legacy attachment) +const gameCard = { + type: 'AdaptiveCard' as const, + version: '1.5', + fallback: 'You are unable to choose a game', + body: [ + { + type: 'TextBlock', + text: 'Would you like to play a game?', + size: 'Medium', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: 'Choose a game to play', + wrap: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Chess', + data: { verb: 'wopr_game', game: 'chess' }, + }, + { + type: 'Action.Submit', + title: "Falken's Maze", + data: { verb: 'wopr_game', game: 'maze' }, + }, + { + // Dangerous action — use Action.ShowCard for confirmation + type: 'Action.ShowCard', + title: 'Thermonuclear War', + card: { + type: 'AdaptiveCard', + body: [ + { + type: 'TextBlock', + text: "Are you sure? Wouldn't you prefer a good game of chess?", + wrap: true, + color: 'Attention', + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Yes', + style: 'destructive', + data: { verb: 'wopr_game', game: 'war' }, + }, + // "No" simply collapses the ShowCard — no action needed + ], + }, + }, + ], +}; + +// Send the game card when the user says "play" +app.on('message', async ({ activity, send }) => { + if (activity.text?.match(/play/i)) { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: gameCard, + }], + }); + } +}); + +// Handle game selection (replaces app.attachmentAction("wopr_game")) +// TODO: Replace with app.adaptiveCards.actionSubmit if using teams-ai SDK +app.on('adaptiveCards.actionSubmit' as any, async ({ activity, send }) => { + const data = activity.value; + if (data?.verb === 'wopr_game') { + const game = data.game; + await send(`You chose: ${game}. Let's play!`); + // TODO: Send the follow-up card (replaces secondMessage / replace_original) + } +}); + +app.start(3978); +``` + +### Mapping reference table + +| Slack Legacy Attachment | Adaptive Card Equivalent | Notes | +|---|---|---| +| `attachments[].text` | `TextBlock` in `body` | Convert mrkdwn to standard Markdown | +| `attachments[].fallback` | Card-level `fallback` property | For notifications and accessibility | +| `attachments[].color` (`"good"`) | `Container` with `style: "good"` | Green styling | +| `attachments[].color` (`"warning"`) | `Container` with `style: "warning"` | Yellow styling | +| `attachments[].color` (`"danger"`) | `Container` with `style: "attention"` | Red styling | +| `attachments[].color` (`"#hex"`) | `Container` with `style: "emphasis"` | No arbitrary hex; use closest semantic style | +| `attachments[].callback_id` | `Action.Submit.data.verb` | Routing key for action handlers | +| `actions[].type: "button"` | `Action.Submit` | `name`/`value` → `data` keys | +| `actions[].style: "danger"` | `Action.Submit` with `style: "destructive"` | | +| `actions[].confirm` | `Action.ShowCard` with confirm sub-card | Or two-step confirmation flow | +| `actions[].type: "select"` | `Input.ChoiceSet` + `Action.Submit` | Requires explicit submit button | +| `attachment_type: "default"` | *(remove)* | No equivalent needed | +| `app.attachmentAction(id)` | `app.adaptiveCards.actionSubmit(verb)` | Or `app.on('adaptiveCards.actionSubmit')` | +| `respond({ replace_original })` | `updateActivity(activityId, card)` | Must store original activity ID | + +## pitfalls + +- **No arbitrary colors**: Slack attachments support any hex color via the `color` field. Adaptive Cards only support semantic styles (`"good"`, `"warning"`, `"attention"`, `"emphasis"`, `"accent"`, `"default"`). Map to the closest semantic meaning rather than exact color matching. +- **Confirmation dialogs require redesign**: Slack's `confirm` object is a built-in dialog. Adaptive Cards have no equivalent. `Action.ShowCard` is the closest — it reveals an inline sub-card. For a modal confirmation, use a task module flow instead. +- **Select fires differently**: Slack legacy selects fire immediately on selection. Adaptive Card `Input.ChoiceSet` requires a separate `Action.Submit` click. This changes the UX — inform users of the change. +- **Mixed attachments + blocks**: Some Slack messages combine legacy attachments with Block Kit blocks. Merge both into a single Adaptive Card. The attachment text becomes `TextBlock`s at the top, followed by the converted Block Kit elements. +- **`replace_original` requires activity ID**: Slack's `respond({ replace_original: true })` works with just the `response_url`. In Teams, you need the original activity ID to call `updateActivity()`. Store the activity ID when you send the card (returned from `send()`). +- **`callback_id` routing**: Slack routes attachment actions by `callback_id`. Teams routes by the `verb` (or custom key) in `Action.Submit.data`. Ensure every button includes a routing key in its `data` object. + +## references + +- https://api.slack.com/reference/messaging/attachments — Slack legacy attachments (deprecated but supported) +- https://api.slack.com/legacy/interactive-messages — Legacy interactive messages (attachment actions) +- https://adaptivecards.io/explorer/Action.ShowCard.html — Action.ShowCard (inline reveal) +- https://adaptivecards.io/explorer/Container.html — Container with style property +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference — Teams card reference +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack legacy attachments or Teams Adaptive Cards. It covers converting attachment JSON to Adaptive Card JSON and vice versa, mapping `attachmentAction` handlers to `actionSubmit` handlers, redesigning confirmation dialogs, handling message replacement, and dealing with mixed attachment + Block Kit messages. For Teams → Slack, Adaptive Cards can be mapped to legacy attachment format if targeting very old Slack integrations, though Block Kit is preferred. Pair with `ui-block-kit-adaptive-cards-ts.md` if the message also contains Block Kit blocks, and `../teams/ui.adaptive-cards-ts.md` for Adaptive Card construction patterns. + +## research + +Deep Research prompt: + +"Write a micro expert on bridging Slack legacy message attachments (pre-Block Kit) and Teams Adaptive Cards in either direction for cross-platform bots. Cover: attachment text/color/fallback/callback_id/actions mapping, button and select action conversion, confirm dialog redesign with Action.ShowCard, attachmentAction handler bridging to adaptiveCards.actionSubmit, replace_original to updateActivity, mixed attachments + Block Kit messages, color mapping limitations, and reverse-direction notes for Teams → Slack legacy attachment mapping. Include a worked example converting between formats." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/ui-modals-dialogs-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-modals-dialogs-ts.md new file mode 100644 index 000000000..e72645ee0 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/ui-modals-dialogs-ts.md @@ -0,0 +1,451 @@ +# ui-modals-dialogs-ts + +## purpose + +Bridges Slack modal workflows and Teams task module / dialog flows for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack `views.open(trigger_id, view)` maps to Teams `dialog.open` handler. In Slack, the app calls `ctx.client().viewsOpen()` with a `trigger_id` from a slash command or interaction. In Teams, the dialog opens when the user clicks an `Action.Submit` with `{ msteams: { type: 'task/fetch' } }` in its data, or from a manifest command. The `dialog.open` handler returns the card form. +2. Slack `app.viewSubmission(callback_id)` maps to Teams `app.on('dialog.submit', handler)`. Slack provides form data in `view.state.values[block_id][action_id]`; Teams provides it in `activity.value.data` as a flat object keyed by Adaptive Card input `id`s. +3. Slack `viewsUpdate` (updating the current modal) maps to returning a `continue` response from `dialog.submit` with a new card. Slack's `ctx.ack({ response_action: 'update', view: newView })` becomes returning `{ status: 200, body: { task: { type: 'continue', value: { title, card } } } }`. +4. Slack `views.push` (stacking a new modal) has no Teams equivalent. Teams task modules do not support stacking. Flatten multi-modal stacks into a single multi-step dialog with step routing in `dialog.submit`, or redesign as sequential cards in the chat. +5. Slack `app.viewClosed(callback_id)` (`notify_on_close: true`) has no direct Teams equivalent. Teams does not notify the bot when a user closes/cancels a task module. If cleanup is needed, handle it via timeout or the next user interaction. For critical cleanup, consider storing pending state and reconciling on the next bot message. +6. Slack field-level validation with `ctx.ackWithErrors({ block_id: "error message" })` (which keeps the modal open and shows inline errors) has no server-side equivalent in Teams. Use Adaptive Card client-side validation (`isRequired`, `errorMessage`, `regex`, `min`, `max`) for pre-submit validation. For server-side validation that fails, return a `continue` response with the form re-rendered including error `TextBlock`s, or return a `message` response with the error text. +7. Slack `private_metadata` (arbitrary string stored on the view) maps to embedding hidden state in `Action.Submit.data` fields. Include any round-trip state (original command args, IDs, step indicators) in the card's submit action `data` object. +8. Slack `blockSuggestion` (typeahead/external data source for selects inside modals) maps to Adaptive Card `Input.ChoiceSet` with `"style": "filtered"` for client-side filtering, or `Data.Query` with dynamic data source for server-side filtering (schema 1.6+, limited Teams support). For most cases, pre-populate the choices at dialog open time instead of dynamic fetching. +9. Slack `blockAction` inside modals (responding to user interactions mid-form without submitting) has no Teams equivalent. Adaptive Card inputs do not fire events until `Action.Submit` is clicked. If the Slack modal updated dynamically based on a selection, redesign as: (a) multi-step dialog (submit step 1, return step 2 card), or (b) pre-compute all variants and include conditional data in the initial card. +10. Slack modal `title`, `submit`, and `close` labels map to task module `title` (in the `value` object) and Adaptive Card `Action.Submit` button titles. There is no separate close button label — the task module always shows a platform X button. + +## patterns + +### Slash command → modal → submit (full flow) + +**Slack (before):** + +```kotlin +// Slash command opens a modal +app.command("/meeting") { _, ctx -> + val res = ctx.client().viewsOpen { + it.triggerId(ctx.triggerId).viewAsString(modalJson) + } + if (res.isOk) ctx.ack() + else Response.builder().statusCode(500).body(res.error).build() +} + +// Handle submission +app.viewSubmission("meeting-arrangement") { req, ctx -> + val stateValues = req.payload.view.state.values + val agenda = stateValues["agenda"]!!["agenda-input"]!!.value + val errors = mutableMapOf() + if (agenda.length <= 10) { + errors["agenda"] = "Agenda needs to be longer than 10 characters." + } + if (errors.isNotEmpty()) { + ctx.ackWithErrors(errors) + } else { + ctx.ack() + } +} + +// Handle close +app.viewClosed("meeting-arrangement") { _, ctx -> ctx.ack() } +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('meeting-bot'), +}); + +// Step 1: Send a message with a button that triggers dialog.open +app.on('message', async ({ activity, send }) => { + if (activity.text?.match(/\/meeting/i)) { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [{ type: 'TextBlock', text: 'Click below to arrange a meeting.' }], + actions: [{ + type: 'Action.Submit', + title: 'Arrange Meeting', + data: { msteams: { type: 'task/fetch' } }, + }], + }, + }], + }); + } +}); + +// Step 2: dialog.open returns the form card (replaces views.open) +app.on('dialog.open', async () => { + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Meeting Arrangement', + width: 'medium', + height: 'medium', + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'Input.Date', + id: 'meetingDate', + label: 'Meeting Date', + }, + { + type: 'Input.ChoiceSet', + id: 'topics', + label: 'Topics', + isMultiSelect: true, + style: 'filtered', + choices: [ + { title: 'Schedule', value: 'schedule' }, + { title: 'Budget', value: 'budget' }, + { title: 'Assignment', value: 'assignment' }, + ], + }, + { + type: 'Input.Text', + id: 'agenda', + label: 'Detailed Agenda', + isMultiline: true, + isRequired: true, + errorMessage: 'Agenda is required', + }, + ], + actions: [{ + type: 'Action.Submit', + title: 'Submit', + data: { action: 'meeting-arrangement' }, + }], + }, + }, + }, + }, + }, + }; +}); + +// Step 3: dialog.submit handles form data (replaces viewSubmission) +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + const agenda: string = data.agenda ?? ''; + + // Server-side validation (replaces ctx.ackWithErrors) + if (agenda.length <= 10) { + // Return the form again with an error message + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Meeting Arrangement', + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Agenda needs to be longer than 10 characters.', + color: 'Attention', + weight: 'Bolder', + }, + // ... repeat form fields with previous values pre-filled ... + ], + actions: [{ + type: 'Action.Submit', + title: 'Submit', + data: { action: 'meeting-arrangement' }, + }], + }, + }, + }, + }, + }, + }; + } + + // Success — close the dialog + return { + status: 200, + body: { + task: { + type: 'message', + value: `Meeting arranged! Date: ${data.meetingDate}, Topics: ${data.topics}`, + }, + }, + }; +}); + +// Note: No viewClosed equivalent — Teams does not notify on dialog cancel. + +app.start(3978); +``` + +### Mapping reference table + +| Slack Modal Concept | Teams Dialog Equivalent | Notes | +|---|---|---| +| `views.open(trigger_id, view)` | `dialog.open` handler returning `continue` response | Triggered by `Action.Submit` with `msteams: { type: 'task/fetch' }` | +| `viewSubmission(callback_id)` | `dialog.submit` handler | Form data in `activity.value.data` (flat object) | +| `ctx.ack()` (close modal) | Return `{ task: { type: 'message', value } }` | Message shown briefly, then dialog closes | +| `ctx.ack({ response_action: 'update', view })` | Return `{ task: { type: 'continue', value: { card } } }` | Replaces dialog content | +| `ctx.ack({ response_action: 'push', view })` | *(no equivalent)* | Flatten into multi-step dialog | +| `ctx.ackWithErrors(errors)` | Return `continue` with error TextBlocks, or use client-side validation | No native field-level error API | +| `viewClosed(callback_id)` | *(no equivalent)* | Teams does not notify on cancel | +| `private_metadata` | `Action.Submit.data` fields | Embed state in submit action | +| `view.state.values[block_id][action_id]` | `activity.value.data[inputId]` | Flat key-value vs nested structure | +| `blockSuggestion` (typeahead) | `Input.ChoiceSet` with `style: "filtered"` | Client-side only; pre-populate choices | +| `blockAction` mid-form | *(no equivalent)* | Redesign as multi-step dialog | +| Modal `title` / `submit` / `close` labels | `value.title` + `Action.Submit.title` | No custom close label | + +### Dynamic selects best practice (Y9) + +Pre-populate `Input.ChoiceSet` with `style: "filtered"` for datasets under 500 items. For larger datasets, use a two-step dialog. + +```typescript +// Small dataset (<500 items): pre-populate with client-side filtering +function buildSelectCard(users: { name: string; email: string }[]): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "Input.ChoiceSet", + id: "user_select", + label: "Assign to", + style: "filtered", // enables client-side typeahead search + choices: users.map(u => ({ title: u.name, value: u.email })), + }], + actions: [{ type: "Action.Submit", title: "Assign", data: { action: "assign" } }], + }; +} + +// Large dataset (>500 items): two-step dialog +// Step 1: text input for search query +function buildSearchStep(): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "Input.Text", id: "search_query", + label: "Search users", placeholder: "Type a name...", + }], + actions: [{ type: "Action.Submit", title: "Search", data: { action: "search_users", step: 1 } }], + }; +} + +// Step 2: submit handler queries server, returns filtered ChoiceSet +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value.data; + if (data?.action === "search_users" && data.step === 1) { + const results = await searchUsers(data.search_query); // server-side query + return { + status: 200, + body: { task: { type: "continue", value: { + title: "Select User", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: buildSelectCard(results), // now a small filtered set + }, + }}}, + }; + } +}); +``` + +**Don't:** Build a web-based task module just for a searchable dropdown. The effort (16–24 hrs) rarely justifies the marginal UX improvement over two-step. + +**Reverse (Teams → Slack):** Use `external_data_source: true` on select elements with `app.options()` for server-side typeahead. + +### Cancel detection workaround: TTL + Cancel button (R3) + +Teams does not notify the bot when a dialog is dismissed. Add an explicit "Cancel" button and a timeout to handle cleanup. + +```typescript +// Track pending dialog state with TTL +const pendingDialogs = new Map(); + +// When opening a dialog, record the pending state +app.on('dialog.open', async ({ activity }) => { + const userId = activity.from?.aadObjectId ?? ''; + const dialogId = `dlg_${Date.now()}`; + pendingDialogs.set(dialogId, { + userId, + lockedResource: 'ticket-123', + expiresAt: Date.now() + 5 * 60_000, // 5-minute TTL + }); + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Edit Ticket', + card: buildFormCard(dialogId), // embed dialogId in Action.Submit.data + }, + }, + }, + }; +}); + +// Handle explicit Cancel button (inside the dialog) +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + if (data?.action === 'cancel') { + pendingDialogs.delete(data.dialogId); + releaseLock(data.dialogId); + return { status: 200, body: { task: { type: 'message', value: 'Cancelled.' } } }; + } + // Handle normal submit... + pendingDialogs.delete(data.dialogId); + return { status: 200, body: { task: { type: 'message', value: 'Saved!' } } }; +}); + +// Periodic cleanup of expired dialogs (user closed without clicking Cancel) +setInterval(() => { + const now = Date.now(); + for (const [id, state] of pendingDialogs) { + if (state.expiresAt < now) { + releaseLock(id); + pendingDialogs.delete(id); + } + } +}, 60_000); // check every minute +``` + +**Reverse (Teams → Slack):** Use `notify_on_close: true` in `views.open()` and handle `viewClosed` natively. + +### Multi-step dialog workaround: step routing (R4/R6) + +Replace Slack's `views.push()` stacking and `dispatch_action` mid-form updates with a single dialog using step routing. + +```typescript +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + const step = data?.step ?? 1; + + if (data?.action === 'back') { + return buildStepResponse(step - 1, data); + } + if (data?.action === 'next') { + // Validate current step + const errors = validateStep(step, data); + if (errors.length > 0) { + return buildStepResponse(step, data, errors); // re-render with errors (R5) + } + if (step >= 3) { + // Final step — process all data + await processWizard(data); + return { status: 200, body: { task: { type: 'message', value: 'Done!' } } }; + } + return buildStepResponse(step + 1, data); + } +}); + +function buildStepResponse(step: number, previousData: Record, errors: string[] = []) { + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: `Step ${step} of 3`, + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', version: '1.5', + body: [ + // Show validation errors if any (R5 workaround) + ...errors.map(e => ({ + type: 'TextBlock', text: e, color: 'Attention', weight: 'Bolder', + })), + // Step-specific fields + ...getStepFields(step, previousData), + ], + actions: [ + ...(step > 1 ? [{ + type: 'Action.Submit', title: 'Back', + data: { ...previousData, step, action: 'back' }, + }] : []), + { + type: 'Action.Submit', + title: step === 3 ? 'Finish' : 'Next', + data: { ...previousData, step, action: 'next' }, + }, + { + type: 'Action.Submit', title: 'Cancel', + data: { ...previousData, step, action: 'cancel' }, + }, + ], + }, + }, + }, + }, + }, + }; +} +``` + +**Key principle:** Every step's `Action.Submit.data` must carry forward ALL data from previous steps, since there's no persistent modal state like Slack's `private_metadata`. + +**Reverse (Teams → Slack):** Use `views.push()` for stacking (up to 3 levels) and `dispatch_action: true` + `views.update()` for mid-form dynamics. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, map `dialog.open` to `views.open` with `trigger_id`, `dialog.submit` to `viewSubmission`, and Adaptive Card inputs to Block Kit inputs. Key reverse mappings: +- `dialog.open` handler returning `continue` → `views.open(trigger_id, view)` -- note: Slack requires a `trigger_id` from a preceding interaction (slash command, button click, etc.) +- `dialog.submit` handler → `app.view('callback_id', ...)` with `view.state.values[block_id][action_id]` +- `activity.value.data[inputId]` (flat) → `view.state.values[block_id][action_id].value` (nested) +- Return `{ task: { type: 'continue', value: { card } } }` → `ctx.ack({ response_action: 'update', view: newView })` +- Return `{ task: { type: 'message', value } }` → `ctx.ack()` (close modal) +- Multi-step dialog (routing by `data.step`) → `views.push` for stacked modals (Slack supports stacking) +- Error `TextBlock` re-render → `ctx.ackWithErrors({ block_id: 'error message' })` for inline field-level errors +- `Action.Submit.data` hidden fields → `private_metadata` string on the view +- `Input.ChoiceSet` with `style: "filtered"` → `blockSuggestion` handler for server-side typeahead +- Adaptive Card `isRequired`/`errorMessage`/`regex` client-side validation → server-side validation in `viewSubmission` with `ackWithErrors` +- No cancel notification (Teams) → `viewClosed(callback_id)` with `notify_on_close: true` (Slack supports cancel callbacks) + +## pitfalls + +- **No modal stacking**: Slack's `views.push` stacks modals. Teams task modules cannot stack. Redesign stacked flows as multi-step forms within a single dialog (route by `data.step` in the submit handler). +- **No cancel notification**: Slack's `viewClosed` handler fires when a user clicks Cancel (with `notify_on_close: true`). Teams has no equivalent. Do not rely on cancel callbacks for critical state cleanup. +- **Validation UX is different**: Slack's `ackWithErrors` shows inline red text under specific fields and keeps the modal open. Teams has no server-side field-level error API. Use Adaptive Card `isRequired`/`errorMessage`/`regex` for client-side checks. For server-side failures, return a `continue` response with an error `TextBlock` added to the card. +- **Form data structure change**: Slack nests form data as `view.state.values[block_id][action_id].value`. Teams flattens it as `activity.value.data[inputId]`. The nesting is gone — input `id`s must be unique across the entire card. +- **Trigger mechanism change**: Slack opens modals from `trigger_id` (passed in slash command and interaction payloads). Teams opens dialogs from `Action.Submit` with `msteams: { type: 'task/fetch' }` or from manifest commands. There is no free-standing "open dialog" API call. +- **Dynamic selects**: Slack's `blockSuggestion` fires on each keystroke to fetch options server-side. Adaptive Card `Input.ChoiceSet` with `style: "filtered"` only filters pre-populated choices client-side. For truly dynamic data, pre-fetch at dialog open time or use `Data.Query` (limited support). +- **Mid-form interactions lost**: Slack modals can respond to `blockAction` events mid-form (e.g., showing/hiding fields based on a dropdown). Adaptive Cards do not fire events until submit. Redesign conditional forms as multi-step dialogs. +- **Returning nothing closes with error**: If the `dialog.submit` handler returns `undefined`, Teams shows a generic error. Always return a valid `{ status: 200, body: { task: { ... } } }` response. + +## references + +- https://api.slack.com/surfaces/modals — Slack modal documentation +- https://api.slack.com/surfaces/modals/using#pushing — Stacking views with views.push +- https://api.slack.com/surfaces/modals/using#closing — notify_on_close and viewClosed +- https://api.slack.com/reference/interaction-payloads/views — view_submission payload +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots — Teams task modules +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when bridging Slack modal workflows and Teams dialog/task module flows in either direction. It covers the full lifecycle: opening (`views.open` ↔ `dialog.open`), submission (`viewSubmission` ↔ `dialog.submit`), updating (`response_action: update` ↔ `continue` response), stacking (`views.push` ↔ multi-step redesign), closing (`viewClosed` ↔ no Teams equivalent), validation (`ackWithErrors` ↔ client-side + `continue`), and dynamic selects (`blockSuggestion` ↔ filtered `ChoiceSet`). Use when adding cross-platform support in either direction. Pair with `ui-block-kit-adaptive-cards-ts.md` for converting modal Block Kit to Adaptive Card elements (or vice versa), `../teams/ui.dialogs-task-modules-ts.md` for Teams-side dialog patterns, and `../teams/ui.adaptive-cards-ts.md` for card construction. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack modals and Teams task modules / dialogs bidirectionally. Cover views.open <-> dialog.open, viewSubmission <-> dialog.submit, viewsUpdate <-> continue response, viewClosed <-> no equivalent, blockSuggestion <-> filtered ChoiceSet, blockAction <-> no equivalent, ackWithErrors <-> client-side validation, private_metadata <-> Action.Submit.data, and notify_on_close. Include a comprehensive bidirectional mapping table, a full worked example showing both directions, and pitfalls around stacking, validation, cancel notification, and dynamic selects." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/workflow.composable-platform-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/workflow.composable-platform-ts.md new file mode 100644 index 000000000..fe42b5588 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/workflow.composable-platform-ts.md @@ -0,0 +1,297 @@ +# workflow.composable-platform-ts + +## purpose + +Architectural guide for building a composable, reusable workflow operating layer inside Teams — the five-element framework (trigger, state, logic, intelligence, visibility) as a platform pattern, not a point solution. + +## rules + +1. **Every workflow follows the same five-element lifecycle.** (1) Trigger — how it starts, (2) State — where records live, (3) Logic — how decisions and automation execute, (4) Intelligence — how AI is layered over state, (5) Visibility — how records remain embedded in channels. Design every workflow as an instantiation of this lifecycle. +2. **Define workflows as configuration, not code.** A workflow definition specifies: trigger type + parameters, list schema (columns and types), routing rules (approval chain, auto-assign), query functions (NL schemas), and card templates (active/completed/error). The runtime consumes these definitions generically. +3. **Use a `WorkflowDefinition` interface as the core abstraction.** This interface describes the workflow's schema, triggers, routing, and card templates. The runtime registers handlers dynamically from definitions. New workflows require a new definition object, not new handler code. +4. **Template workflows are reference implementations.** Provide polished, out-of-the-box definitions for common scenarios: time-off requests, equipment booking, daily standup, account health. These serve as both usable workflows and examples for customization. +5. **The runtime is a generic workflow engine.** A single set of handlers (message, `card.action`, proactive, webhooks) dispatch to the correct workflow based on the verb/command prefix in the message or action data. The engine creates records, processes actions, and renders cards for any registered workflow. +6. **SharePoint Lists are the default state backend.** Each workflow definition maps to a SharePoint list. The engine creates lists on first use, following the schema in the definition. For enterprise needs, swap to Dataverse without changing the workflow definition. +7. **Card templates are parameterized, not hardcoded.** Define card templates as functions that take a record and return an Adaptive Card. The workflow definition includes templates for: `activeCard`, `completedCard`, `listCard`, and `formCard`. The engine calls the right template based on record state. +8. **Query functions are auto-generated from the schema.** Given a workflow definition's column schema, generate AI function-calling schemas automatically: each filterable column becomes a parameter. This eliminates writing per-workflow query functions manually. +9. **Extensibility points for ecosystem partners.** The composable platform should expose: (a) custom trigger types (plugin new event sources), (b) custom logic steps (plugin business rules), (c) custom card templates (brand and layout), (d) custom state backends (plugin storage). Each point has a defined interface. +10. **Cross-workflow queries are first-class.** The engine registers a `queryAnyWorkflow` function that searches across all registered workflow lists. Users ask "what's overdue?" and get results from PTO, equipment, and standup workflows combined. +11. **Power Automate integration is optional, not required.** The composable platform can execute logic in-bot (state machine) or delegate to Power Automate flows. Workflow definitions specify `executionMode: "bot" | "powerAutomate" | "hybrid"`. Bot mode is the default for SMB; Power Automate mode for enterprise. + +## patterns + +### WorkflowDefinition interface + +```typescript +interface WorkflowDefinition { + id: string; // Unique workflow identifier + name: string; // Display name + description: string; // Used in command suggestions and AI descriptions + commandPrefix: string; // e.g., "/pto", "/book", "/standup" + + // Schema + columns: ColumnDefinition[]; // Maps to SharePoint List columns + statusField: string; // Which column tracks lifecycle state + statusValues: { + active: string[]; // e.g., ["Pending", "InProgress"] + completed: string[]; // e.g., ["Approved", "Rejected", "Done"] + }; + + // Triggers + triggers: TriggerConfig[]; + + // Routing + routing?: { + type: "none" | "single" | "sequential" | "parallel-any" | "parallel-all"; + approverSource: "fixed" | "manager" | "field"; // Where to find the approver + approverField?: string; // Column name if approverSource is "field" + escalationTimeoutMs?: number; + }; + + // Cards + cards: { + active: (record: any) => object; + completed: (record: any) => object; + list: (records: any[]) => object; + form?: () => object; // For message extension action trigger + }; + + // AI + queryDescription: string; // Describes when AI should call the query function + filterableColumns: string[]; // Columns exposed as AI function parameters +} + +interface ColumnDefinition { + name: string; + type: "text" | "number" | "dateTime" | "choice" | "personOrGroup" | "boolean"; + choices?: string[]; // For choice columns + required?: boolean; +} + +interface TriggerConfig { + type: "command" | "messageExtension" | "scheduled" | "stateChange"; + config: Record; // Trigger-specific configuration +} +``` + +### Register a workflow from a definition + +```typescript +function registerWorkflow(app: any, engine: WorkflowEngine, definition: WorkflowDefinition) { + // Command trigger + const commandTrigger = definition.triggers.find((t) => t.type === "command"); + if (commandTrigger) { + const regex = new RegExp(`^\\${definition.commandPrefix}\\s*(.*)$`, "i"); + app.message(regex, async (ctx: any) => { + await engine.handleCommand(ctx, definition); + }); + } + + // Scheduled trigger + const scheduledTrigger = definition.triggers.find((t) => t.type === "scheduled"); + if (scheduledTrigger) { + cron.schedule(scheduledTrigger.config.cron, async () => { + await engine.handleScheduled(definition); + }); + } + + // Register AI query function + engine.registerQueryFunction(definition); +} +``` + +### Generic workflow engine + +```typescript +class WorkflowEngine { + private definitions = new Map(); + private graphClient: Client; + private siteId: string; + private lists = new Map(); // workflowId -> listId + + async handleCommand(ctx: any, def: WorkflowDefinition) { + const params = parseCommandParams(ctx.activity.text!, def); + const record = await this.createRecord(def, { + ...params, + requesterId: ctx.activity.from?.aadObjectId, + requesterName: ctx.activity.from?.name, + conversationId: ctx.activity.conversation?.id, + serviceUrl: ctx.activity.serviceUrl, + }); + + const card = def.cards.active(record); + const response = await ctx.send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }], + }); + + // Store activity ID for future updates + await this.updateRecordField(def, record.id, "CardActivityId", response.id); + + // Start escalation timer if routing is configured + if (def.routing?.escalationTimeoutMs) { + this.startEscalation(def, record); + } + } + + async handleAction(ctx: any, verb: string, data: any) { + const def = this.definitions.get(data.workflowId); + if (!def) return; + + const record = await this.getRecord(def, data.recordId); + + if (verb === "approve" || verb === "reject") { + return this.processApproval(ctx, def, record, verb, data.comment); + } + + if (verb.startsWith("refresh")) { + const card = record.status === "completed" + ? def.cards.completed(record) + : def.cards.active(record); + return { + status: 200, + body: { + statusCode: 200, + type: "application/vnd.microsoft.card.adaptive", + value: card, + }, + }; + } + } + + registerQueryFunction(def: WorkflowDefinition) { + // Auto-generate AI function schema from definition + const parameters: Record = {}; + for (const col of def.filterableColumns) { + const colDef = def.columns.find((c) => c.name === col); + if (!colDef) continue; + + switch (colDef.type) { + case "choice": + parameters[col] = { type: "string", enum: colDef.choices }; + break; + case "dateTime": + parameters[col] = { type: "string", description: `Filter by ${col} (ISO date)` }; + break; + case "personOrGroup": + parameters[col] = { type: "string", description: `Filter by ${col} name` }; + break; + default: + parameters[col] = { type: "string" }; + } + } + + return { + name: `query_${def.id}`, + description: def.queryDescription, + parameters: { type: "object", properties: parameters }, + }; + } + + private async createRecord(def: WorkflowDefinition, fields: Record) { + const listId = await this.ensureList(def); + const item = await this.graphClient + .api(`/sites/${this.siteId}/lists/${listId}/items`) + .post({ fields }); + return { id: item.id, ...item.fields }; + } + + private async ensureList(def: WorkflowDefinition): Promise { + if (this.lists.has(def.id)) return this.lists.get(def.id)!; + + // Check if list exists, create if not + try { + const existing = await this.graphClient + .api(`/sites/${this.siteId}/lists`) + .filter(`displayName eq '${def.name}'`) + .get(); + + if (existing.value.length > 0) { + this.lists.set(def.id, existing.value[0].id); + return existing.value[0].id; + } + } catch { /* List doesn't exist */ } + + const list = await this.graphClient + .api(`/sites/${this.siteId}/lists`) + .post({ + displayName: def.name, + list: { template: "genericList" }, + columns: def.columns.map(colDefToGraphColumn), + }); + + this.lists.set(def.id, list.id); + return list.id; + } +} +``` + +### Template workflow: Time-Off Request + +```typescript +const ptoWorkflow: WorkflowDefinition = { + id: "pto", + name: "PTO Requests", + description: "Time-off and vacation request workflow", + commandPrefix: "/pto", + columns: [ + { name: "Requester", type: "personOrGroup", required: true }, + { name: "StartDate", type: "dateTime", required: true }, + { name: "EndDate", type: "dateTime", required: true }, + { name: "HoursRequested", type: "number" }, + { name: "Status", type: "choice", choices: ["Pending", "Approved", "Rejected"] }, + { name: "ApprovedBy", type: "personOrGroup" }, + { name: "Reason", type: "text" }, + ], + statusField: "Status", + statusValues: { + active: ["Pending"], + completed: ["Approved", "Rejected"], + }, + triggers: [ + { type: "command", config: { pattern: "/pto START to END" } }, + { type: "messageExtension", config: { commandId: "createPto" } }, + ], + routing: { + type: "single", + approverSource: "manager", + escalationTimeoutMs: 48 * 60 * 60 * 1000, // 48 hours + }, + cards: { + active: buildPtoActiveCard, + completed: buildPtoCompletedCard, + list: buildPtoListCard, + }, + queryDescription: "Query PTO/time-off requests. Use when user asks about PTO, vacation, leave, days off.", + filterableColumns: ["Status", "Requester", "StartDate"], +}; + +// Register +registerWorkflow(app, engine, ptoWorkflow); +``` + +## pitfalls + +- **Over-abstraction kills velocity.** The composable platform should start with 2-3 template workflows and extract common patterns. Don't build the full generic engine before validating with real workflows. +- **Schema migrations are hard.** Once a SharePoint List is created, adding required columns or changing types is disruptive. Version your schemas and handle missing columns gracefully. +- **Generic engines produce generic cards.** Template card functions should be polished, not auto-generated. The best workflow UX comes from purpose-built card layouts, not generic field renderers. +- **Power Automate hybrid mode adds complexity.** Supporting both bot-native and Power Automate execution means two code paths, two monitoring surfaces, and two failure modes. Default to bot-native for the FHL; add Power Automate later. +- **Ecosystem extensibility requires stable interfaces.** Don't expose extension points until the core patterns stabilize through 3+ real workflow implementations. + +## references + +- https://learn.microsoft.com/en-us/graph/api/resources/list +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview +- https://learn.microsoft.com/en-us/power-automate/getting-started + +## instructions + +Use this expert when designing the overall composable workflow architecture. Covers the five-element lifecycle framework, WorkflowDefinition interface, generic engine patterns, template workflows, auto-generated AI query schemas, and extensibility points. Pair with `../teams/workflow.sharepoint-lists-ts.md` for state persistence, `../teams/workflow.message-native-records-ts.md` for card-as-record patterns, `../teams/workflow.triggers-compose-ts.md` for trigger unification, `../teams/ai.conversational-query-ts.md` for NL retrieval, and `../teams/workflow.approvals-inline-ts.md` for approval routing. + +## research + +Deep Research prompt: + +"Write a micro expert on designing a composable workflow platform inside Microsoft Teams (TypeScript). Cover: five-element lifecycle framework (trigger, state, logic, intelligence, visibility), WorkflowDefinition configuration interface, generic workflow engine that dispatches from definitions, template/reference workflows (PTO, equipment, standup), auto-generated AI function schemas from column definitions, SharePoint Lists as pluggable state backend, Power Automate hybrid execution mode, and ecosystem extensibility points. Include complete patterns for the definition interface, engine registration, and one template workflow." diff --git a/skills/microsoft-365-agents-toolkit/experts/bridge/workflows-automation-ts.md b/skills/microsoft-365-agents-toolkit/experts/bridge/workflows-automation-ts.md new file mode 100644 index 000000000..370d02600 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/bridge/workflows-automation-ts.md @@ -0,0 +1,247 @@ +# workflows-automation-ts + +## purpose + +Bridges Slack Workflow Builder and Teams Power Automate / bot-driven orchestration for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack Workflow Builder → Power Automate flows (manual rebuild required).** There is no automated migration tool. Slack workflows are drag-and-drop automations with triggers and steps. Power Automate flows serve the same purpose but with a completely different builder, trigger system, and step library. Each workflow must be manually recreated. [learn.microsoft.com -- Power Automate](https://learn.microsoft.com/en-us/power-automate/getting-started) +2. **Slack workflow triggers → Power Automate triggers.** Slack triggers include: webhook, shortcut, new channel message, emoji reaction, user joins channel. Power Automate equivalents: HTTP request (webhook), Teams message trigger, approval trigger, Recurrence (scheduled), and 400+ connectors. Map each trigger individually. [learn.microsoft.com -- Triggers](https://learn.microsoft.com/en-us/power-automate/triggers-introduction) +3. **Slack custom steps (`workflow_step_execute`) → Power Automate custom connectors.** Slack bots can register custom workflow steps that appear in the Workflow Builder. In Power Automate, the equivalent is a custom connector wrapping your bot's REST API. The connector defines actions, inputs, and outputs that appear in the flow designer. [learn.microsoft.com -- Custom connectors](https://learn.microsoft.com/en-us/connectors/custom-connectors/) +4. **Slack approval workflows → Power Automate Approvals connector (built-in).** Slack workflows that collect approvals via emoji reactions or form submissions map to Power Automate's native Approvals connector. It provides: approval request creation, approval/rejection actions, parallel/sequential approvals, and approval history. No custom code needed. [learn.microsoft.com -- Approvals](https://learn.microsoft.com/en-us/power-automate/get-started-approvals) +5. **Teams "Workflows" app provides simple in-Teams automations.** For basic workflows (post to channel on schedule, notify on form submission), the Workflows app in Teams provides templates without leaving Teams. It's powered by Power Automate under the hood but has a simplified UI. [learn.microsoft.com -- Workflows app](https://learn.microsoft.com/en-us/microsoftteams/platform/m365-apps/publish-app#workflows) +6. **Bot-driven workflow alternative: state machine + Adaptive Card buttons.** For workflows that don't fit Power Automate's model (complex branching, dynamic participants, long-running multi-step processes), implement a state machine in the bot. Each step sends an Adaptive Card with action buttons; button clicks advance the state. Store workflow state in Cosmos DB or similar. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +7. **Slack `workflow_step` event lifecycle → custom connector action lifecycle.** Slack's workflow step has `edit` (configure step), `save` (persist config), `execute` (run step). Power Automate custom connectors define: action schema (inputs/outputs in OpenAPI), and the runtime HTTP call. There is no separate "edit" flow — the connector schema defines the UI. [learn.microsoft.com -- Connector actions](https://learn.microsoft.com/en-us/connectors/custom-connectors/define-blank#define-the-action) +8. **Slack workflow variables → Power Automate dynamic content.** Slack workflows pass data between steps via variables set in earlier steps. Power Automate uses "dynamic content" — outputs from previous steps that can be referenced in later steps. The data flow model is similar but the syntax is completely different. [learn.microsoft.com -- Dynamic content](https://learn.microsoft.com/en-us/power-automate/use-expressions-in-conditions) +9. **Power Automate flows can call Bot Framework via HTTP.** To integrate your Teams bot into a Power Automate flow, expose REST endpoints on your bot's server and call them from Power Automate's HTTP action. The bot can then send proactive messages based on flow triggers. [learn.microsoft.com -- HTTP connector](https://learn.microsoft.com/en-us/connectors/custom-connectors/) +10. **Slack Workflow Builder is free; Power Automate has licensing tiers.** Slack Workflow Builder is included in all plans. Power Automate has a free tier (limited runs) and premium tiers. Custom connectors require a premium license. Factor licensing into migration planning. [learn.microsoft.com -- Power Automate licensing](https://learn.microsoft.com/en-us/power-platform/admin/pricing-billing-skus) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, Power Automate flows can be mapped to Slack Workflow Builder steps or custom `workflow_step_execute` handlers. Power Automate Approvals map to Slack approval workflows using emoji reactions or interactive message buttons. Power Automate custom connectors map to Slack custom workflow steps registered via `workflow_step` events. Power Automate Recurrence triggers map to Slack Workflow Builder scheduled triggers. + +## patterns + +### Approval workflow → Power Automate Approvals + +**Slack Workflow Builder (before):** + +The Slack workflow is configured in the GUI: +1. Trigger: User submits a form (custom step) +2. Step 1: Send form data to `#approvals` channel +3. Step 2: Wait for `:white_check_mark:` reaction from approver +4. Step 3: Post result to `#completed` channel + +**Bot code for custom approval step:** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Custom workflow step execution +app.event("workflow_step_execute", async ({ event, client }) => { + const { workflow_step } = event; + const inputs = workflow_step.inputs; + + // Post approval request + const msg = await client.chat.postMessage({ + channel: "#approvals", + text: `Approval needed: ${inputs.request_text.value}`, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `*Approval Request*\n${inputs.request_text.value}` }, + }, + { type: "section", text: { type: "mrkdwn", text: "React with :white_check_mark: to approve or :x: to reject." } }, + ], + }); + + // Watch for reaction (simplified — real implementation uses reaction_added event) +}); +``` + +**Teams (after) — Power Automate flow (described as JSON definition):** + +```json +{ + "definition": { + "triggers": { + "manual": { + "type": "Request", + "kind": "Button", + "inputs": { + "schema": { + "type": "object", + "properties": { + "requestText": { "type": "string", "title": "Request details" }, + "requesterEmail": { "type": "string", "title": "Requester email" } + } + } + } + } + }, + "actions": { + "Start_approval": { + "type": "OpenApiConnection", + "inputs": { + "host": { "connectionName": "shared_approvals" }, + "operationId": "StartAndWaitForAnApproval", + "parameters": { + "approvalType": "Basic", + "ApprovalCreationInput/title": "Approval: @{triggerBody()?['requestText']}", + "ApprovalCreationInput/assignedTo": "approver@company.com", + "ApprovalCreationInput/details": "@{triggerBody()?['requestText']}" + } + } + }, + "Post_result_to_Teams": { + "type": "OpenApiConnection", + "inputs": { + "host": { "connectionName": "shared_teams" }, + "operationId": "PostMessageToConversation", + "parameters": { + "poster": "Flow bot", + "location": "Channel", + "body/recipient": "completed-channel-id", + "body/messageBody": "Request @{outputs('Start_approval')?['body/title']} was @{outputs('Start_approval')?['body/outcome']}" + } + }, + "runAfter": { "Start_approval": ["Succeeded"] } + } + } + } +} +``` + +**Bot-driven alternative (for complex approval logic):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Approval state machine +interface ApprovalRequest { + id: string; + text: string; + requester: string; + status: "pending" | "approved" | "rejected"; + activityId?: string; +} + +const approvals = new Map(); + +// Create approval request +app.message(/^\/?approve (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?approve\s+/i, "") ?? ""; + const id = `apr_${Date.now()}`; + const approval: ApprovalRequest = { + id, + text, + requester: activity.from?.name ?? "Unknown", + status: "pending", + }; + approvals.set(id, approval); + + const response = await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Approval Request", weight: "Bolder", size: "Medium" }, + { type: "TextBlock", text: `**From:** ${approval.requester}`, wrap: true }, + { type: "TextBlock", text: approval.text, wrap: true }, + ], + actions: [ + { type: "Action.Execute", title: "Approve", verb: "approveAction", data: { approvalId: id } }, + { type: "Action.Execute", title: "Reject", verb: "rejectAction", data: { approvalId: id } }, + ], + }, + }], + }); +}); + +// Handle approval/rejection buttons +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + const approval = approvals.get(data?.approvalId); + + if (!approval) return { status: 200, body: {} }; + + const isApprove = data?.verb === "approveAction"; + approval.status = isApprove ? "approved" : "rejected"; + const reviewer = activity.from?.name ?? "Someone"; + + // Return updated card (replaces original) + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Approval Request", weight: "Bolder", size: "Medium" }, + { type: "TextBlock", text: approval.text, wrap: true }, + { + type: "TextBlock", + text: `**${approval.status.toUpperCase()}** by ${reviewer}`, + color: isApprove ? "Good" : "Attention", + weight: "Bolder", + }, + ], + // No actions — card is now read-only + }, + }; +}); + +app.start(3978); +``` + +### Migration approach comparison + +| Slack Workflow Feature | Power Automate | Bot-Driven | Teams Workflows App | +|---|---|---|---| +| Visual builder | Yes (full designer) | No (code) | Yes (simplified) | +| Custom steps | Custom connectors | Handler code | No | +| Approval flows | Built-in Approvals | Card buttons + state | No | +| Scheduled triggers | Recurrence trigger | Timer + proactive | Yes (basic) | +| Complex branching | Yes (conditions, loops) | State machine | No | +| License cost | Free tier + Premium | Bot hosting cost | Free | +| Developer skill needed | Low-code | TypeScript | None | + +## pitfalls + +- **No automated migration**: Every Slack workflow must be manually recreated in Power Automate or bot code. There is no import/export compatibility. Plan for significant manual effort on large workflow portfolios. +- **Reaction-based approvals break completely**: Slack workflows commonly use emoji reactions as approval signals. Teams has no equivalent pattern in Power Automate. Use the built-in Approvals connector or Action.Execute card buttons. +- **Custom connector licensing**: Power Automate custom connectors (needed to replace Slack custom workflow steps) require a Premium license. The free tier does not support custom connectors. +- **Slack workflow variables vs Power Automate dynamic content**: The data passing model is similar in concept but completely different in syntax. Slack uses `{{variable_name}}`; Power Automate uses `@{outputs('step_name')?['property']}`. This is a manual translation. +- **Bot-driven workflows require state persistence**: Unlike Power Automate which manages state internally, bot-driven approval workflows need external state storage (Cosmos DB, SQL). Without it, workflow state is lost on bot restart. +- **Power Automate flow limits**: Free tier is limited to 750 runs/month. Standard is 10,000/month. High-volume workflows (processing hundreds of requests daily) may require premium plans. + +## references + +- https://learn.microsoft.com/en-us/power-automate/getting-started +- https://learn.microsoft.com/en-us/power-automate/get-started-approvals +- https://learn.microsoft.com/en-us/connectors/custom-connectors/ +- https://learn.microsoft.com/en-us/power-automate/triggers-introduction +- https://learn.microsoft.com/en-us/power-automate/use-expressions-in-conditions +- https://learn.microsoft.com/en-us/power-platform/admin/pricing-billing-skus +- https://github.com/microsoft/teams.ts +- https://api.slack.com/workflows — Slack Workflow Builder +- https://api.slack.com/workflows/steps — Slack custom workflow steps + +## instructions + +Use this expert when adding cross-platform support in either direction for workflow automation. It covers: Slack Workflow Builder bridged to Power Automate flows, custom workflow steps bridged to Power Automate custom connectors, approval workflows bridged to the Approvals connector, the Teams Workflows app for simple automations, bot-driven workflow alternatives using state machines + Adaptive Cards, and reverse mapping from Power Automate flows back to Slack Workflow Builder steps and custom workflow_step_execute handlers. Pair with `../teams/ui.adaptive-cards-ts.md` for card construction in bot-driven workflows, `../teams/runtime.proactive-messaging-ts.md` for flow-triggered bot messages, and `slack-interactive-responses-to-teams-ts.md` for card replacement patterns in approval flows. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Workflow Builder and Microsoft Teams Power Automate / bot-driven orchestration in either direction. Cover: Power Automate as the Teams-side replacement, custom connector creation for Slack custom workflow steps, the built-in Approvals connector for approval flows, the Teams Workflows app for simple automations, bot-driven state machine alternative with Adaptive Card buttons, workflow trigger mapping, variable/dynamic content translation, licensing considerations, and reverse mapping from Power Automate flows back to Slack Workflow Builder steps. Include code examples for bot-driven approvals and a comparison table." diff --git a/skills/microsoft-365-agents-toolkit/experts/builder.md b/skills/microsoft-365-agents-toolkit/experts/builder.md new file mode 100644 index 000000000..3f13933cf --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/builder.md @@ -0,0 +1,182 @@ +# builder + +## purpose + +Guided workflow for creating new micro-experts — from scoping and research through drafting, validation, and wiring into the routing system. + +## rules + +1. **One expert, one topic.** Each expert file covers a single, well-bounded topic. If the scope needs an "and" to describe it, split into two experts. +2. **Minimum depth threshold.** Only create a standalone expert if the topic warrants 8+ rules and 2+ code patterns. Below that threshold, add the knowledge to an existing expert instead. +3. **Reusability over specificity.** The expert must apply to future tasks, not just the current one-off request. If the knowledge is project-specific, it belongs in a CLAUDE.md or README, not an expert. +4. **Research before writing.** Never draft rules or patterns from memory alone. Every rule must trace to official docs, SDK source, or verified behavior. If you cannot confirm a claim, mark it `[unverified]`. +5. **Language-agnostic filenames when appropriate.** Use `{topic}-ts.md` when the expert is TypeScript-specific. Use `{topic}.md` (no language suffix) when the expert applies regardless of language (e.g., architecture patterns, workflow guides, platform concepts). +6. **Canonical section order.** Every expert MUST follow the section layout in the expert structure reference below. Omit optional sections entirely rather than leaving them empty. +7. **Rules are imperatives, not observations.** Write "Always call `ack()` before async work" not "ack is important." Each rule must tell the reader exactly what to do or avoid. +8. **Patterns are minimal and self-contained.** Each code snippet demonstrates one concept with all necessary imports. No "see above" references between patterns. +9. **Pitfalls earn their place.** Only include pitfalls that are non-obvious, have bitten real users, or contradict reasonable assumptions. "Don't forget to save the file" is not a pitfall. +10. **No fabricated API signatures.** If a web search yields no confirmation for an API shape, omit it or mark it `[unverified]`. Wrong patterns are worse than missing patterns. +11. **Wire it or it doesn't exist.** An expert that isn't reachable through the routing system (domain `index.md` + root `index.md` signals) will never be loaded. Integration is not optional. +12. **Keep files under 300 lines.** If an expert grows beyond 300 lines, split it into focused sub-experts under the same domain. + +## interview + +### Q1 — Topic & Language +``` +question: "What topic should this expert cover, and is it language-specific?" +header: "Topic" +options: + - label: "TypeScript-specific" + description: "Expert targets TypeScript patterns and APIs. File will be named {topic}-ts.md." + - label: "Language-agnostic" + description: "Expert covers concepts that apply across languages. File will be named {topic}.md." + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +### Q2 — Research Depth +``` +question: "How much research should go into this expert before drafting?" +header: "Research" +options: + - label: "Full deep research (Recommended)" + description: "Web search official docs, SDK source, and community guides for each rule and pattern. Thorough but slower." + - label: "Light research" + description: "Quick scan of official docs only. Good when you already have strong domain knowledge." + - label: "Stub only" + description: "Create the file structure with a research prompt but no content yet. Fill in later with the researcher workflow." +multiSelect: false +``` + +### Q3 — Placement +``` +question: "Where should this expert live in the folder structure?" +header: "Placement" +options: + - label: "Existing domain folder" + description: "Place in an existing domain (languages/, tools/, .project/). You'll specify which." + - label: "New domain folder" + description: "Create a new domain folder. Only if 3+ experts will belong to it and it has distinct signal words." + - label: "Root .experts/ folder" + description: "Place at the root level alongside fallback.md. For system-level utilities only." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | TypeScript-specific (`{topic}-ts.md`) | +| Q2 | Full deep research | +| Q3 | Existing domain folder | + +## workflow + +### phase 1 — scope + +1. Run the interview above (or use defaults if the developer opted out). +2. Confirm the topic doesn't overlap with an existing expert. Read the target domain's `index.md` file inventory and scan for coverage. +3. If overlap exists, recommend updating the existing expert instead and stop. +4. Decide the filename: `{topic}-ts.md` (language-specific) or `{topic}.md` (language-agnostic). +5. Decide the target folder: existing domain, new domain, or root `.experts/`. + +### phase 2 — research + +1. Write a Deep Research prompt for the topic. Include: SDK/platform name, key concepts, specific APIs to cover, and pattern areas. +2. Execute the prompt as a series of targeted web searches: + - Break into discrete topics (one per API surface, concept, or pattern area). + - Search each individually. Prefer official docs, SDK source, and type definitions. + - For each result, capture: API signatures, parameter types, return types, defaults, and gotchas. +3. If interview answer was "Stub only," write the research prompt into `## research` and skip to phase 5 (integration). The expert will be a stub. +4. If interview answer was "Light research," do a quick scan of official docs only — skip community guides and deep dives. + +### phase 3 — draft + +Write the expert file following the canonical section layout from the expert structure reference below. + +1. **`## purpose`** — One line. What does this expert cover? +2. **`## rules`** — Numbered list of actionable imperatives. Minimum 8 rules for a non-stub expert. Each rule should cite its source (doc link or observed SDK behavior). +3. **`## interview`** (optional) — Include only if the expert requires developer decisions before implementation. Follow the AskUserQuestion format shown in the expert structure reference below. +4. **`## patterns`** — Code snippets showing canonical usage. Each snippet is self-contained with imports. Minimum 2 patterns for a non-stub expert. +5. **`## pitfalls`** — Non-obvious mistakes, breaking changes, version gotchas. +6. **`## references`** — URLs to official docs and SDK source used during research. +7. **`## instructions`** — When to use this expert, what it pairs with (`Pair with: {other-expert}`). +8. **`## research`** — The Deep Research prompt (preserved for future re-research). + +### phase 4 — validate + +Run through this checklist before considering the expert done: + +- [ ] **Minimum depth**: 8+ rules, 2+ patterns (unless intentionally a stub). +- [ ] **Pattern isolation**: Every code snippet compiles in isolation (imports included, no "see above"). +- [ ] **No fabrication**: Every API signature confirmed via research. Unverified claims marked `[unverified]`. +- [ ] **File size**: Under 300 lines. If over, identify split points. +- [ ] **Section completeness**: All required sections present (`purpose`, `rules`, `instructions`, `research`). Optional sections either fully populated or entirely absent. +- [ ] **Rules are imperatives**: Each rule tells the reader what to do/avoid, not what "is" or "exists." +- [ ] **Pitfalls are non-obvious**: No trivial advice. Each pitfall would surprise a competent developer. +- [ ] **Cross-references set**: `## instructions` includes `Pair with:` entries for related experts. + +### phase 5 — integrate + +Wire the new expert into the routing system so it's reachable: + +1. **Domain `index.md`** — Open the target domain's `index.md`: + - Add the file to the appropriate task cluster's `Read:` list (or create a new cluster with a `When:` description). + - Add `Depends on:` / `Cross-domain deps:` if applicable. + - Add the filename to `## file inventory` in alphabetical order. +2. **Root `index.md`** — Open `.experts/index.md`: + - If the new expert introduces signal words not already in the domain's `Signals:` line, add them. + - If this is a new domain, add a full routing entry under `## routing rules`. +3. **Verify routing** — Mentally trace a request that should reach this expert: root router signals → domain router → task cluster → expert file. Confirm the path is unbroken. + +## expert structure reference + +This is the canonical section layout every expert must follow. Required sections are marked; optional sections should be omitted entirely if not needed. + +``` +# {topic}-ts | {topic} ← filename without .md + +## purpose ← REQUIRED. One line. + +## rules ← REQUIRED. Numbered imperatives. + +## interview ← OPTIONAL. Delete if no upfront decisions needed. +### Q1 — {Decision} +(AskUserQuestion format) +### defaults table +(Required if interview exists) + +## patterns ← REQUIRED for non-stubs. Code snippets. + +## pitfalls ← RECOMMENDED. Non-obvious gotchas. + +## references ← RECOMMENDED. Source URLs. + +## instructions ← REQUIRED. When to use, Pair with. + +## research ← REQUIRED. Deep Research prompt. +``` + +## pitfalls + +- **Creating experts for one-off knowledge.** If the topic won't come up again, don't create an expert. Add a note to the relevant domain expert or CLAUDE.md instead. +- **Skipping integration (phase 5).** The most common failure mode. An expert that isn't wired into the routing system is invisible and will never be loaded. +- **Writing rules from memory without research.** Even experienced developers misremember API details. Always verify against current docs — APIs change between SDK versions. +- **Cramming multiple topics into one file.** An expert on "state management and adaptive cards and function calling" should be three experts. The scope test: can you describe it without "and"? +- **Empty optional sections.** An empty `## pitfalls` section signals the author didn't try. Either populate it with real gotchas or omit the section entirely. +- **Forgetting the language suffix decision.** A TypeScript-specific expert named `caching.md` (without `-ts`) will confuse future users about whether it's language-agnostic. Be deliberate about the naming. + +## instructions + +Use this expert when creating any new micro-expert file. + +**Trigger phrases:** "create expert," "new expert," "build expert," "add expert," "make expert," "write expert." + +Pair with: `fallback.md` (if the builder is invoked because fallback detected a knowledge gap that warrants a new expert). + +## research + +Deep Research prompt: + +"Write a meta-expert for creating micro-expert prompt files in a modular AI expert system. Cover: scoping criteria (when to create vs. update), research methodology (web search strategies for SDK docs, source code, type definitions), canonical section layout for expert files, quality validation checklists (minimum rules, pattern isolation, no fabrication), integration steps (domain router wiring, signal word updates), and common failure modes in expert authoring. Include guidance on language-agnostic vs. language-specific naming conventions." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/bulk-conversion-strategy-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/bulk-conversion-strategy-ts.md new file mode 100644 index 000000000..f25f305ee --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/bulk-conversion-strategy-ts.md @@ -0,0 +1,274 @@ +# bulk-conversion-strategy-ts + +## purpose + +Strategy and workflow for large-scale code conversion — converting 100+ source files (Java POJOs, Ruby classes, JS modules) to TypeScript efficiently with prioritized phases, incremental validation, and tooling. + +## rules + +1. **Never attempt a big-bang conversion.** Convert in phases, ensuring each phase compiles and passes tests before proceeding. A half-converted project that compiles is infinitely better than a fully-converted project that doesn't. +2. **Phase order: Models → Utilities → Core logic → Handlers → Entry point.** Convert bottom-up through the dependency graph. Models have no internal dependencies, so they convert first. Entry points depend on everything, so they convert last. +3. **Prioritize by dependency count.** Run a dependency analysis: files imported by many others convert first (high fan-in). Files that import many others convert last (high fan-out). This minimizes the number of temporary `any` shims. +4. **Use TypeScript's `allowJs: true`** during transition. This lets `.ts` files coexist with unconverted `.js` files. Set `checkJs: false` to avoid type-checking JS files. Remove `allowJs` only when 100% of files are converted. +5. **Create a `@types/source-project` declarations file** for unconverted modules. As you convert models first, other unconverted files may still import them. A `.d.ts` shim keeps the compiler happy during the transition. +6. **Batch similar files.** Group files by pattern (all Lombok `@Data` POJOs, all event handlers, all middleware) and convert each group in one pass. This builds muscle memory and ensures consistency. +7. **Validate each batch immediately.** After converting a batch: (1) `tsc --noEmit` to check types, (2) run relevant tests, (3) commit. Do not accumulate unconverted batches. +8. **Track progress with a conversion manifest.** Maintain a simple JSON or markdown file listing every source file, its status (pending/in-progress/done/skipped), target TS file, and notes. This prevents duplicate work and makes progress visible. +9. **Handle the 80/20 rule.** ~80% of files in a Java project are simple POJOs/models that convert mechanically. ~20% contain complex logic (middleware, async chains, polymorphic factories) that need careful manual conversion. Identify the 20% early and plan extra time. +10. **Establish naming conventions before starting.** Decide once: snake_case API fields stay snake_case or become camelCase? One file per class (Java-style) or group by feature (TS-style)? Barrel exports or direct imports? Document in the conversion manifest. +11. **Write adapter/shim layers for incremental testing.** If the source project has integration tests, create thin adapter layers so converted TS modules can be called from unconverted test harnesses (or vice versa) during transition. +12. **Delete source files after conversion, don't keep both.** Having `User.java` and `User.ts` side by side causes confusion. Once `User.ts` compiles and tests pass, delete `User.java`. The git history preserves the original. + +## interview + +### Q1 — Naming Conventions +``` +question: "How should internal field names be cased in the converted TypeScript code?" +header: "Field casing" +options: + - label: "camelCase (Recommended)" + description: "Convert all internal fields to camelCase (standard TS convention). Wire-format fields (API JSON) keep their original casing via serialization mapping." + - label: "Keep original casing" + description: "Preserve snake_case/PascalCase from the source language as-is. Fewer changes but non-idiomatic TS." + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +### Q2 — File Organization +``` +question: "How should converted files be organized?" +header: "File layout" +options: + - label: "Group by feature (Recommended)" + description: "Organize files by feature/module (TS-style). Related types, handlers, and utilities live together." + - label: "One file per class" + description: "Keep the source language's structure (e.g., Java's one-class-per-file). Familiar but can lead to many small files." +multiSelect: false +``` + +### Q3 — Export Style +``` +question: "How should modules be exported?" +header: "Exports" +options: + - label: "Barrel exports (Recommended)" + description: "Each directory gets an index.ts re-exporting its public API. Cleaner imports for consumers." + - label: "Direct imports only" + description: "Import directly from each file path. No barrel files. Simpler but more verbose import paths." +multiSelect: false +``` + +### Q4 — Conversion Scope +``` +question: "Should we convert everything, or focus on specific modules first?" +header: "Scope" +options: + - label: "Full project (phased)" + description: "Convert the entire project in dependency order (models -> utils -> core -> handlers -> entry). Recommended for clean breaks." + - label: "Critical path only" + description: "Convert only the modules needed for the current feature/migration. Remaining modules use .d.ts shims." + - label: "Models + utilities only" + description: "Convert data models and shared utilities. Keep handlers/entry points in source language with interop layer." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | camelCase for internal, preserve wire-format | +| Q2 | Group by feature | +| Q3 | Barrel exports | +| Q4 | Full project (phased) | + +## patterns + +### Conversion manifest tracking file + +```markdown +# Conversion Manifest — java-slack-sdk + +## Conventions +- API wire-format fields: keep snake_case +- Internal fields: camelCase +- One interface per model file (may group related types) +- Barrel exports via index.ts per directory + +## Phase 1: Models (200 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| +| model/block/SectionBlock.java | done | src/models/blocks/section-block.ts | | +| model/block/ActionsBlock.java | done | src/models/blocks/actions-block.ts | | +| model/block/DividerBlock.java | done | src/models/blocks/divider-block.ts | | +| model/event/AppMentionEvent.java | in-progress | src/models/events/app-mention-event.ts | Has nested types | +| model/event/MessageEvent.java | pending | src/models/events/message-event.ts | | +| ... | | | | + +## Phase 2: Utilities (15 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 3: Core Services (30 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 4: Handlers (40 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 5: Entry Points (5 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| +``` + +### Dependency graph analysis for prioritization + +```typescript +// Script to analyze Java import graph and determine conversion order +import { readFileSync, readdirSync } from 'fs'; +import { join, relative } from 'path'; + +interface FileNode { + path: string; + imports: string[]; // files this file imports + importedBy: string[]; // files that import this file (fan-in) +} + +function analyzeJavaImports(srcDir: string): FileNode[] { + const files: Map = new Map(); + + // Scan all .java files + function scan(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + scan(fullPath); + } else if (entry.name.endsWith('.java')) { + const rel = relative(srcDir, fullPath); + const content = readFileSync(fullPath, 'utf-8'); + const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)] + .map((m) => m[1].replace(/\./g, '/') + '.java'); + files.set(rel, { path: rel, imports, importedBy: [] }); + } + } + } + scan(srcDir); + + // Build reverse dependency map (fan-in) + for (const [path, node] of files) { + for (const imp of node.imports) { + const target = files.get(imp); + if (target) { + target.importedBy.push(path); + } + } + } + + // Sort: highest fan-in first (most depended-on → convert first) + return [...files.values()].sort( + (a, b) => b.importedBy.length - a.importedBy.length, + ); +} + +// Usage: +const ordered = analyzeJavaImports('./java-slack-sdk/slack-api-model/src/main/java'); +console.log('Convert in this order:'); +ordered.slice(0, 20).forEach((f) => + console.log(` ${f.path} (imported by ${f.importedBy.length} files)`), +); +``` + +### Batch conversion script for Lombok @Data POJOs + +```typescript +// Semi-automated: reads Java @Data class, outputs TS interface stub +function convertDataClass(javaSource: string): string { + const lines = javaSource.split('\n'); + const className = lines + .find((l) => l.includes('class ')) + ?.match(/class\s+(\w+)/)?.[1] ?? 'Unknown'; + + const fields: { name: string; type: string; serializedName?: string }[] = []; + let serializedName: string | undefined; + + for (const line of lines) { + const snMatch = line.match(/@SerializedName\("(\w+)"\)/); + if (snMatch) { + serializedName = snMatch[1]; + continue; + } + + const fieldMatch = line.match( + /private\s+(?:final\s+)?(\w+(?:<[\w<>,\s]+>)?)\s+(\w+)\s*;/, + ); + if (fieldMatch) { + fields.push({ + type: mapJavaType(fieldMatch[1]), + name: serializedName ?? fieldMatch[2], + serializedName, + }); + serializedName = undefined; + } + } + + const fieldLines = fields + .map((f) => ` ${f.name}: ${f.type};`) + .join('\n'); + + return `export interface ${className} {\n${fieldLines}\n}\n`; +} + +function mapJavaType(javaType: string): string { + const map: Record = { + String: 'string', + boolean: 'boolean', + Boolean: 'boolean', + int: 'number', + Integer: 'number', + long: 'number', + Long: 'number', + double: 'number', + Double: 'number', + float: 'number', + Float: 'number', + }; + if (map[javaType]) return map[javaType]; + if (javaType.startsWith('List<')) { + const inner = javaType.slice(5, -1); + return `${mapJavaType(inner)}[]`; + } + if (javaType.startsWith('Map<')) { + const [k, v] = javaType.slice(4, -1).split(',').map((s) => s.trim()); + return `Record<${mapJavaType(k)}, ${mapJavaType(v)}>`; + } + return javaType; // Keep as-is for custom types (will need manual mapping) +} +``` + +## pitfalls + +- **Converting everything before testing anything**: The biggest risk. Convert 5 model files, compile, test, commit. Then the next 5. Never go more than ~20 files without validating. +- **Ignoring the dependency graph**: Converting a handler before its model types exist forces you to use `any` everywhere, creating tech debt you'll forget to clean up. +- **Inconsistent naming conventions**: If file 1 uses `threadTs` and file 50 uses `thread_ts` for the same field, you'll have runtime bugs. Establish and document conventions in the manifest BEFORE starting. +- **Keeping source and target files**: Having `User.java` and `user.ts` in the repo simultaneously leads to confusion about which is authoritative. Delete the source after confirming the target works. +- **Automating too much**: Semi-automated scripts (like the POJO converter above) produce ~70% correct output. Always review and adjust. Fully automated conversion produces subtle type errors that are harder to find later. +- **Not tracking progress**: After converting 50 of 200 files, it's easy to lose track of what's done. The manifest file is essential for multi-session conversion work. +- **Skipping the hard 20%**: It's tempting to convert all easy POJOs and declare victory. The complex files (middleware, async chains, polymorphic factories) are where the real conversion effort lives. Plan them explicitly. +- **Breaking the build for days**: Use `allowJs: true` to keep the project buildable throughout. If the project must stay deployable during conversion, maintain a working build at all times. + +## references + +- https://www.typescriptlang.org/tsconfig#allowJs -- allowJs for incremental migration +- https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html -- official migration guide +- https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html -- .d.ts files for shims + +## instructions + +Use this expert when facing a large-scale conversion (50+ source files). Before converting any code, read this expert to establish the phase order, create a conversion manifest, and run dependency analysis. Pair with the appropriate language expert (`java-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `js-to-ts-ts.md`) for per-file conversion rules, and `json-serialization-ts.md` for serialization-heavy model conversion. + +## research + +Deep Research prompt: + +"Write a micro expert for large-scale language conversion strategy (100+ files from Java/Ruby/JS to TypeScript). Cover: phased conversion order (models → utils → core → handlers → entry), dependency graph analysis for prioritization, conversion manifest tracking, batch processing patterns, allowJs incremental migration, naming convention decisions, validation checkpoints, and common failure modes in big conversions." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/dependency-mapping-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/dependency-mapping-ts.md new file mode 100644 index 000000000..18cefce5f --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/dependency-mapping-ts.md @@ -0,0 +1,117 @@ +# dependency-mapping-ts + +## purpose + +Cross-language dependency mapping — finding npm/TypeScript equivalents for Ruby gems, Java Maven artifacts, and Python pip packages commonly found in Slack bot projects. + +## rules + +1. Always check if an `@types/{package}` exists on DefinitelyTyped before declaring a package as untyped. Run `npm info @types/{package}` or search https://www.npmjs.com/~types. +2. Prefer packages with built-in TypeScript types over untyped packages + `@types` shims. A package exporting its own `.d.ts` is better maintained than relying on community type definitions. +3. When no npm equivalent exists for a gem or Maven artifact, first check if the functionality is built into Node.js (e.g., `crypto`, `http`, `fs`, `url`, `path`, `util`). Many small gems/JARs solve problems that Node.js handles natively. +4. For HTTP clients: Ruby `faraday`/`httparty`/`net/http` and Java `OkHttp`/`HttpClient`/`RestTemplate` all map to `fetch` (built-in since Node 18) or `undici` for advanced use cases. Avoid adding `axios` or `got` unless you need interceptors or retry logic. +5. For web frameworks: Ruby `sinatra` → `express` or `fastify`. Ruby `rails` → `express` + individual packages for ORM, validation, etc. Java `Spring Boot` → `express` or `fastify` + middleware. Do NOT look for a single Rails/Spring equivalent — the Node ecosystem is modular. +6. For testing: Ruby `rspec`/`minitest` → `vitest` or `jest`. Java `JUnit`/`TestNG` → `vitest` or `jest`. Java `Mockito` → `vitest` built-in mocking or `jest.fn()`. +7. For environment/config: Ruby `dotenv` → `dotenv`. Java `System.getenv()` → `process.env`. Java Spring `@Value` / `application.properties` → `dotenv` + typed config module. +8. For JSON handling: Ruby `json` (stdlib) and Java `Jackson`/`Gson` → built-in `JSON.parse()`/`JSON.stringify()`. For schema validation, use `zod` or `ajv`. +9. For database access: Ruby `activerecord`/`sequel` → `prisma`, `drizzle`, or `knex`. Java `Hibernate`/`JPA` → `prisma` or `typeorm`. Java `JDBC` → `pg` (Postgres) / `mysql2` / `better-sqlite3` with raw queries. +10. For scheduling/cron: Ruby `clockwork`/`whenever` → `node-cron` or `bullmq`. Java `ScheduledExecutorService`/`Quartz` → `node-cron` or `bullmq`. +11. For logging: Ruby `logger` (stdlib) → `pino` or `winston`. Java `SLF4J`/`Logback`/`Log4j` → `pino` (fast, JSON) or `winston` (flexible transports). +12. When replacing a dependency, verify feature parity. A mapping table entry doesn't mean the npm package covers 100% of the original's API. Identify which features the bot actually uses and confirm the replacement supports them. + +## patterns + +### Gem → npm mapping table (common Slack bot gems) + +| Ruby Gem | npm Package | Notes | +|---|---|---| +| `slack-ruby-bot` | `@slack/bolt` | Different API; rewrite handlers | +| `slack-ruby-client` | `@slack/web-api` | Direct API client equivalent | +| `sinatra` | `express` | Route syntax differs; see ruby-to-ts-ts.md | +| `faraday` / `httparty` | `fetch` (built-in) | No extra dependency needed (Node 18+) | +| `json` (stdlib) | `JSON` (built-in) | Native in both; zero effort | +| `dotenv` | `dotenv` | Nearly identical API | +| `redis` / `redis-rb` | `ioredis` | TS-typed, Promise-based | +| `pg` | `pg` + `@types/pg` | Same name, same purpose | +| `activerecord` | `prisma` or `drizzle` | Full rewrite of data layer | +| `rspec` | `vitest` | Describe/it syntax similar | +| `puma` / `unicorn` | N/A | Node handles HTTP serving natively | +| `rake` | `tsx` scripts or `npm scripts` | Task runner built into npm | +| `erb` | Template literals or `ejs` | For HTML templating only | +| `chronic` / `ice_cube` | `date-fns` or `luxon` | Date parsing/recurrence | +| `nokogiri` | `cheerio` | HTML/XML parsing | + +### Maven → npm mapping table (common Slack bot JARs) + +| Maven Artifact | npm Package | Notes | +|---|---|---| +| `com.slack.api:bolt` | `@slack/bolt` | Different API; rewrite handlers | +| `com.slack.api:slack-api-client` | `@slack/web-api` | Direct API client equivalent | +| `org.springframework.boot:*` | `express` + middleware | No single equivalent; modular | +| `com.google.code.gson:gson` | `JSON` (built-in) | Native JSON support | +| `com.fasterxml.jackson.core:*` | `JSON` (built-in) + `zod` | Zod for schema validation | +| `org.apache.httpcomponents:httpclient` | `fetch` (built-in) | No extra dependency (Node 18+) | +| `org.slf4j:slf4j-api` | `pino` or `winston` | Structured logging | +| `ch.qos.logback:logback-classic` | `pino` | Fast JSON logger | +| `org.junit.jupiter:*` | `vitest` | Test framework | +| `org.mockito:*` | `vitest` mocking | Built-in mock support | +| `io.github.cdimascio:dotenv-java` | `dotenv` | Same concept | +| `com.zaxxer:HikariCP` | N/A | Node uses single-thread; pool via `pg` | +| `org.postgresql:postgresql` | `pg` + `@types/pg` | PostgreSQL driver | +| `redis.clients:jedis` | `ioredis` | Redis client | +| `com.google.guava:guava` | Various / built-in | Most Guava utils are native in JS | + +### Dependency audit workflow + +```typescript +// Step 1: Extract all dependencies from the source project +// Ruby: parse Gemfile / Gemfile.lock +// Java: parse pom.xml / build.gradle + +// Step 2: Categorize each dependency +type DepCategory = + | 'builtin' // Covered by Node.js or TS natively + | 'direct-map' // 1:1 npm equivalent exists + | 'rewrite' // Functionality exists but API differs significantly + | 'eliminate' // Language-specific concern (e.g., thread pools, GC tuning) + | 'custom'; // No equivalent; must implement from scratch + +interface DependencyAudit { + source: string; // e.g., "faraday" or "com.google.code.gson:gson" + category: DepCategory; + target: string; // npm package name or "built-in" + notes: string; // Migration notes + typesPackage?: string; // @types/* if needed +} + +// Step 3: Install and verify each mapped dependency +// npm install {package} @types/{package} +// Step 4: Write adapter code for 'rewrite' category deps +``` + +## pitfalls + +- **Don't assume name similarity means API similarity**: Ruby's `redis` gem and npm's `ioredis` serve the same purpose but have completely different APIs. Plan for handler rewrites. +- **Check Node.js built-ins first**: Before adding `uuid`, check if `crypto.randomUUID()` suffices. Before adding `axios`, check if `fetch` works. Before adding `path-to-regexp`, check if your framework already includes routing. +- **Gem/JAR version matters**: A Ruby project on `slack-ruby-bot 0.10` has a very different API from `0.16`. Check the source project's locked version to understand which features are actually used. +- **Transitive dependencies**: Ruby's `Gemfile.lock` and Java's dependency tree include transitive deps. Only map the **direct** dependencies — transitives are handled by the npm package you're switching to. +- **Dev dependencies**: Don't forget to map dev-only tools: `rubocop` → `eslint` + `prettier`, `bundler` → `npm`, `mvn` → `npm scripts`, `pry` → Node debugger. +- **Web server is implicit**: Ruby needs `puma`/`unicorn`/`thin` as a web server. Java needs `tomcat`/`jetty` (embedded in Spring). Node.js `http` module IS the web server — no additional package needed (Express wraps it). + +## references + +- https://www.npmjs.com/ -- npm package registry (search for equivalents) +- https://www.npmjs.com/~types -- DefinitelyTyped @types packages +- https://rubygems.org/ -- Ruby gem registry (for understanding source deps) +- https://search.maven.org/ -- Maven Central (for understanding source deps) +- https://nodejs.org/api/ -- Node.js built-in modules + +## instructions + +Use this expert when auditing and replacing dependencies during a language conversion. Start by extracting all dependencies from the source project (Gemfile, pom.xml, build.gradle, or package.json), then categorize each using the audit workflow pattern. Consult the mapping tables for common equivalents. Pair with the appropriate language conversion expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `java-to-ts-ts.md`) for API-level migration guidance. + +## research + +Deep Research prompt: + +"Write a micro expert for mapping dependencies across languages to TypeScript/npm. Cover: Ruby gems to npm packages, Java Maven artifacts to npm packages, identifying Node.js built-in replacements, @types packages from DefinitelyTyped, dependency audit workflow, and common Slack bot dependency mappings. Include mapping tables for the 15 most common gems and 15 most common Maven artifacts found in chat bot projects." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/index.md b/skills/microsoft-365-agents-toolkit/experts/convert/index.md new file mode 100644 index 000000000..04d34cc52 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/index.md @@ -0,0 +1,79 @@ +# convert-router + +## purpose + +Route language-conversion tasks to the minimal set of micro-expert files. Each expert covers rewriting source code from one language into idiomatic TypeScript. + +## task clusters + +### JS → TypeScript +When: converting JavaScript files to TypeScript, adding types, modernizing imports, enabling strict mode +Read: +- `js-to-ts-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Ruby → TypeScript +When: rewriting Ruby code in TypeScript, translating Ruby idioms, converting gems to npm +Read: +- `ruby-to-ts-ts.md` +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Java → TypeScript +When: rewriting Java code in TypeScript, translating Java OOP patterns, Lombok annotations, CompletableFuture async, converting Maven/Gradle deps to npm +Read: +- `java-to-ts-ts.md` +- `json-serialization-ts.md` +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Kotlin → TypeScript +When: rewriting Kotlin code in TypeScript, trailing lambdas, SAM conversions, `it` implicit parameter, string templates, `trimIndent()`, null-safety operators (`?.`, `!!`, `?:`), `when` expressions, extension functions, data classes, companion objects, sealed classes, `::class.java` references +Read: +- `kotlin-to-ts-ts.md` +- `java-to-ts-ts.md` (Kotlin uses Java SDK types) +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### JSON serialization conversion +When: converting Gson/Jackson serialization to TypeScript JSON + Zod, polymorphic deserialization, @SerializedName mapping +Read: +- `json-serialization-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Bulk/large-scale conversion +When: converting 50+ source files, planning phased conversion, tracking progress across many files +Read: +- `bulk-conversion-strategy-ts.md` +Depends on: The appropriate language-specific expert + +### Cross-language dependency mapping +When: finding npm equivalents for gems, Maven artifacts, or pip packages +Read: +- `dependency-mapping-ts.md` + +### Cross-language type mapping +When: translating type systems between languages, mapping nullable/generic/enum patterns to TypeScript +Read: +- `type-mapping-ts.md` + +### Composite: Full language conversion +When: complete end-to-end source rewrite from any supported language to TypeScript +Read: +- The appropriate language-specific expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, `java-to-ts-ts.md`, or `kotlin-to-ts-ts.md`) +- `json-serialization-ts.md` (if Java source with Gson/Jackson) +- `bulk-conversion-strategy-ts.md` (if 50+ source files) +- `dependency-mapping-ts.md` +- `type-mapping-ts.md` +Cross-domain deps: If also bridging platforms, pair with `../bridge/index.md` for Slack↔Teams or AWS↔Azure concerns. + +## combining rule + +If a request involves **language conversion** and **platform bridging**, read the language-specific expert here first (to rewrite the source), then route through `../bridge/index.md` for platform-specific mapping. + +## file inventory + +`bulk-conversion-strategy-ts.md` | `dependency-mapping-ts.md` | `java-to-ts-ts.md` | `js-to-ts-ts.md` | `json-serialization-ts.md` | `kotlin-to-ts-ts.md` | `ruby-to-ts-ts.md` | `type-mapping-ts.md` + + + diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/java-to-ts-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/java-to-ts-ts.md new file mode 100644 index 000000000..ed7f188f9 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/java-to-ts-ts.md @@ -0,0 +1,448 @@ +# java-to-ts-ts + +## purpose + +Rewriting Java source code as idiomatic TypeScript — mapping Java's class-based OOP, generics, annotations, collections, and concurrency patterns to their TypeScript equivalents. + +## rules + +1. Java class hierarchies map to TypeScript interfaces + classes. Prefer interfaces over abstract classes for defining contracts. Java `implements Interface` maps directly to TypeScript `implements Interface`. Java `extends AbstractClass` maps to TypeScript `extends BaseClass`. +2. Java generics map to TypeScript generics with the same `` syntax. Key difference: Java generics are erased at runtime; TypeScript generics are erased at compile time. Both are structural at their core. Java bounded wildcards (`? extends T`) map to TS constrained generics (``). Java `? super T` has no direct TS equivalent — use a union or contravariant generic. +3. Java annotations (`@Override`, `@Deprecated`, `@JsonProperty`) have no built-in TS equivalent. Map to: TS decorators (experimental, stage 3), JSDoc comments, or runtime metadata patterns. For simple markers like `@Override`, simply remove them — TS enforces override correctness with the `override` keyword. +4. Java `Optional` maps to `T | null` or `T | undefined`. `Optional.of(x)` → just `x`, `Optional.empty()` → `null`, `Optional.isPresent()` → `!= null`, `Optional.map(fn)` → optional chaining + nullish coalescing (`x?.transform() ?? default`). +5. Java checked exceptions do not exist in TypeScript. Remove `throws` declarations from method signatures. Convert `try/catch` blocks but let unexpected errors propagate naturally. Document thrown errors in JSDoc if important for callers. +6. Java `final` maps to `readonly` for class fields and `const` for local variables. Java `final` on method parameters has no TS equivalent (parameters are already effectively final by convention). +7. Java `static` methods and fields map directly to TypeScript `static`. Java static utility classes (e.g., `Collections`, `Math`) often map better to standalone exported functions rather than a class with all-static members. +8. Java `enum` maps to TypeScript `enum` for simple cases, but prefer string literal unions for most use cases. Java enums with methods and fields → TypeScript `as const` object + associated functions or a class hierarchy. +9. Java `Stream` API maps to TypeScript array methods. `stream().filter().map().collect(Collectors.toList())` becomes `.filter().map()`. `Collectors.toMap()` → `reduce()` or `Object.fromEntries()`. `Collectors.groupingBy()` → `Object.groupBy()` or `reduce()`. +10. Java `synchronized` / `volatile` / `Lock` have no TypeScript equivalent (JS is single-threaded). Remove synchronization primitives entirely. If the Java code uses threads for parallelism, redesign around `Promise.all()`, async/await, or worker threads. +11. Java `Map` maps to `Map` (JS built-in) or `Record` for string-keyed maps. `List` → `T[]` or `Array`. `Set` → `Set`. Java `HashMap`/`TreeMap` distinctions are irrelevant — JS `Map` has insertion-order iteration. +12. Java getter/setter pairs (`getName()`/`setName()`) should be simplified to direct property access in TS. Only use `get`/`set` accessors if validation or side effects are needed. +13. Java `StringBuilder` / string concatenation in loops → template literals or `Array.join()`. TS strings are immutable like Java strings, but template literals handle most interpolation needs. +14. Java package structure (`com.example.app.service`) does NOT map to deeply nested TS folders. Flatten to a pragmatic folder structure: `src/services/`, `src/models/`, etc. Use barrel files (`index.ts`) for clean re-exports. +15. **Lombok `@Data`** generates getters, setters, `equals()`, `hashCode()`, `toString()`, and a required-args constructor. In TypeScript, replace with a plain `interface` (for data-only types) or a `class` with `public` constructor parameters. Remove all generated method equivalents — TS doesn't need them. +16. **Lombok `@Builder`** generates a fluent builder class. Replace with a TypeScript options interface: `new Foo({ bar, baz })` or a factory function. The builder pattern is unnecessary when constructors accept named parameters via object destructuring. +17. **Lombok `@Getter`/`@Setter`** on individual fields → direct `public` property access in TS. If the field was `@Getter` only (read-only), use `readonly`. If `@Setter` has custom logic, use a TS `set` accessor. +18. **Lombok `@AllArgsConstructor`/`@NoArgsConstructor`/`@RequiredArgsConstructor`** → TypeScript constructor with explicit parameters. `@NoArgsConstructor` on a data class → all properties optional or have defaults. `@RequiredArgsConstructor` → constructor with only `final` fields as parameters. +19. **Lombok `@Slf4j`** generates a `private static final Logger log` field. Replace with a module-level logger: `import pino from 'pino'; const log = pino({ name: 'MyClass' });` or accept a logger via constructor injection. +20. **Lombok `@Value`** (immutable `@Data`) → TypeScript `interface` with all `readonly` fields, or use `Readonly` utility type. +21. **`CompletableFuture`** maps to `Promise`. `thenApply(fn)` → `.then(fn)`, `thenCompose(fn)` → `.then(fn)` (Promise auto-flattens), `exceptionally(fn)` → `.catch(fn)`, `thenAccept(fn)` → `.then(fn)` (when return is void). +22. **`CompletableFuture.allOf()`** → `Promise.all()`. `CompletableFuture.anyOf()` → `Promise.race()`. `CompletableFuture.supplyAsync(fn, executor)` → just call the async function directly (no executor needed in single-threaded JS). +23. **`CompletableFuture` chains** should be rewritten as `async/await` for readability. A chain of `.thenApply().thenCompose().exceptionally()` becomes a simple `try { const a = await step1(); const b = await step2(a); } catch (e) { ... }`. +24. **`@FunctionalInterface`** annotations → TypeScript function type aliases. `@FunctionalInterface interface Handler { void handle(T t); }` becomes `type Handler = (t: T) => void`. + +## patterns + +### Java class hierarchy → TypeScript interfaces + classes + +```java +// --- Before (Java) --- +public interface MessageHandler { + void handle(Message message); + boolean canHandle(String type); +} + +public abstract class BaseHandler implements MessageHandler { + protected final Logger logger; + + public BaseHandler(Logger logger) { + this.logger = logger; + } + + @Override + public boolean canHandle(String type) { + return getSupportedTypes().contains(type); + } + + protected abstract Set getSupportedTypes(); +} + +public class SlashCommandHandler extends BaseHandler { + private final CommandRegistry registry; + + public SlashCommandHandler(Logger logger, CommandRegistry registry) { + super(logger); + this.registry = registry; + } + + @Override + public void handle(Message message) { + String command = message.getText().split(" ")[0]; + registry.execute(command, message); + } + + @Override + protected Set getSupportedTypes() { + return Set.of("slash_command", "block_actions"); + } +} +``` + +```typescript +// --- After (TypeScript) --- +interface MessageHandler { + handle(message: Message): void; + canHandle(type: string): boolean; +} + +abstract class BaseHandler implements MessageHandler { + constructor(protected readonly logger: Logger) {} + + canHandle(type: string): boolean { + return this.getSupportedTypes().has(type); + } + + abstract handle(message: Message): void; + protected abstract getSupportedTypes(): Set; +} + +class SlashCommandHandler extends BaseHandler { + constructor( + logger: Logger, + private readonly registry: CommandRegistry, + ) { + super(logger); + } + + handle(message: Message): void { + const command = message.text.split(' ')[0]; + this.registry.execute(command, message); + } + + protected getSupportedTypes(): Set { + return new Set(['slash_command', 'block_actions']); + } +} +``` + +### Java Stream API → TypeScript array methods + +```java +// --- Before (Java) --- +import java.util.stream.Collectors; + +List activeUsers = users.stream() + .filter(u -> u.isActive()) + .filter(u -> !u.getRole().equals(Role.GUEST)) + .sorted(Comparator.comparing(User::getName)) + .map(u -> new UserDTO(u.getName(), u.getEmail())) + .collect(Collectors.toList()); + +Map> byDepartment = users.stream() + .collect(Collectors.groupingBy(User::getDepartment)); + +Optional admin = users.stream() + .filter(u -> u.getRole().equals(Role.ADMIN)) + .findFirst(); +``` + +```typescript +// --- After (TypeScript) --- +interface UserDTO { + name: string; + email: string; +} + +const activeUsers: UserDTO[] = users + .filter((u) => u.active) + .filter((u) => u.role !== 'guest') + .sort((a, b) => a.name.localeCompare(b.name)) + .map((u) => ({ name: u.name, email: u.email })); + +const byDepartment: Record = Object.groupBy( + users, + (u) => u.department, +) as Record; + +const admin: User | undefined = users.find((u) => u.role === 'admin'); +``` + +### Java enum with behavior → TypeScript const object + functions + +```java +// --- Before (Java) --- +public enum Priority { + HIGH(1, "High Priority"), + MEDIUM(2, "Medium Priority"), + LOW(3, "Low Priority"); + + private final int level; + private final String label; + + Priority(int level, String label) { + this.level = level; + this.label = label; + } + + public int getLevel() { return level; } + public String getLabel() { return label; } + + public boolean isUrgent() { + return this == HIGH; + } +} +``` + +```typescript +// --- After (TypeScript) --- +const Priority = { + HIGH: { level: 1, label: 'High Priority' }, + MEDIUM: { level: 2, label: 'Medium Priority' }, + LOW: { level: 3, label: 'Low Priority' }, +} as const; + +type PriorityKey = keyof typeof Priority; +type PriorityValue = (typeof Priority)[PriorityKey]; + +function isUrgent(priority: PriorityValue): boolean { + return priority === Priority.HIGH; +} +``` + +### Lombok @Data/@Builder → TypeScript interface + options constructor + +```java +// --- Before (Java with Lombok) --- +import lombok.Builder; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +@Data +@Builder +@Slf4j +public class SlackMessage { + private final String channel; + private final String text; + private final String threadTs; + private final boolean unfurlLinks; + private final List attachments; + + public void send(WebClient client) { + log.info("Sending message to {}", channel); + client.chatPostMessage(r -> r + .channel(channel) + .text(text) + .threadTs(threadTs) + .unfurlLinks(unfurlLinks) + .attachments(attachments)); + } +} + +// Usage with builder: +SlackMessage msg = SlackMessage.builder() + .channel("#general") + .text("Hello!") + .unfurlLinks(false) + .build(); +msg.send(client); +``` + +```typescript +// --- After (TypeScript) --- +import pino from 'pino'; + +const log = pino({ name: 'SlackMessage' }); + +interface SlackMessageOptions { + channel: string; + text: string; + threadTs?: string; + unfurlLinks?: boolean; + attachments?: Attachment[]; +} + +// Interface replaces @Data — no getters/setters/equals/hashCode/toString needed +// Options object replaces @Builder — named params via destructuring +class SlackMessage { + readonly channel: string; + readonly text: string; + readonly threadTs?: string; + readonly unfurlLinks: boolean; + readonly attachments: Attachment[]; + + constructor({ + channel, + text, + threadTs, + unfurlLinks = false, + attachments = [], + }: SlackMessageOptions) { + this.channel = channel; + this.text = text; + this.threadTs = threadTs; + this.unfurlLinks = unfurlLinks; + this.attachments = attachments; + } + + send(client: WebClient): void { + log.info(`Sending message to ${this.channel}`); + client.chat.postMessage({ + channel: this.channel, + text: this.text, + thread_ts: this.threadTs, + unfurl_links: this.unfurlLinks, + attachments: this.attachments, + }); + } +} + +// Usage — options object replaces builder chain: +const msg = new SlackMessage({ + channel: '#general', + text: 'Hello!', + unfurlLinks: false, +}); +msg.send(client); +``` + +### CompletableFuture chain → async/await + +```java +// --- Before (Java) --- +import java.util.concurrent.CompletableFuture; + +public class AsyncSlackClient { + private final MethodsClient client; + private final ExecutorService executor; + + public CompletableFuture fetchAndNotify(String userId, String channel) { + return CompletableFuture.supplyAsync(() -> client.usersInfo(r -> r.user(userId)), executor) + .thenApply(response -> response.getUser().getRealName()) + .thenCompose(name -> CompletableFuture.supplyAsync( + () -> client.chatPostMessage(r -> r.channel(channel).text("Hello " + name)), + executor + )) + .thenApply(response -> response.getTs()) + .exceptionally(ex -> { + log.error("Failed: {}", ex.getMessage()); + return null; + }); + } + + public CompletableFuture> fetchMultipleUsers(List userIds) { + List> futures = userIds.stream() + .map(id -> CompletableFuture.supplyAsync( + () -> client.usersInfo(r -> r.user(id)).getUser().getRealName(), + executor + )) + .collect(Collectors.toList()); + + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList())); + } +} +``` + +```typescript +// --- After (TypeScript) --- +class AsyncSlackClient { + constructor(private readonly client: WebClient) {} + + // CompletableFuture chain → simple async/await + async fetchAndNotify(userId: string, channel: string): Promise { + try { + const userResponse = await this.client.users.info({ user: userId }); + const name = userResponse.user?.real_name ?? 'Unknown'; + const msgResponse = await this.client.chat.postMessage({ + channel, + text: `Hello ${name}`, + }); + return msgResponse.ts ?? null; + } catch (err) { + log.error(`Failed: ${(err as Error).message}`); + return null; + } + } + + // CompletableFuture.allOf → Promise.all + async fetchMultipleUsers(userIds: string[]): Promise { + const results = await Promise.all( + userIds.map(async (id) => { + const response = await this.client.users.info({ user: id }); + return response.user?.real_name ?? 'Unknown'; + }), + ); + return results; + } +} +``` + +### @FunctionalInterface → TypeScript function types + +```java +// --- Before (Java) --- +@FunctionalInterface +public interface BoltEventHandler { + Response apply(EventsApiPayload payload, EventContext context) throws Exception; +} + +@FunctionalInterface +public interface Middleware { + Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception; +} + +// Usage: +app.event(AppMentionEvent.class, (payload, ctx) -> { + ctx.say("Hello!"); + return ctx.ack(); +}); +``` + +```typescript +// --- After (TypeScript) --- +// @FunctionalInterface → type alias for the function signature +type BoltEventHandler = ( + payload: EventsApiPayload, + context: EventContext, +) => Promise; + +type Middleware = ( + req: Request, + resp: Response, + chain: MiddlewareChain, +) => Promise; + +// Usage — identical lambda syntax: +app.event(AppMentionEvent, async (payload, ctx) => { + await ctx.say('Hello!'); + return ctx.ack(); +}); +``` + +## pitfalls + +- **Null vs undefined**: Java has one null; TypeScript has `null` AND `undefined`. Decide on a convention early. Recommendation: use `undefined` for "not provided" (optional params), `null` for "explicitly empty" (API responses). +- **No method overloading at runtime**: Java allows multiple methods with the same name but different signatures. TypeScript supports overload signatures but only one implementation. Merge overloads into a single function with union parameter types. +- **Access modifiers are compile-time only**: TypeScript's `private`/`protected` are erased at runtime (unlike Java). For true runtime privacy, use `#privateField` (ES2022 private fields). +- **No runtime type checking**: Java's `instanceof` checks actual class identity. TypeScript's `instanceof` works for classes but NOT for interfaces (they're erased). Use discriminated unions or type guard functions instead. +- **Collections are not auto-imported**: Java's `List`, `Map`, `Set` are imports from `java.util`. TypeScript's `Array`, `Map`, `Set` are global built-ins — no import needed. But helper methods like `Object.groupBy()` may need a polyfill. +- **Checked exceptions disappear**: Java forces callers to handle checked exceptions. TypeScript has no mechanism for this. Document important error conditions in JSDoc comments. +- **Java `equals()` vs TS `===`**: Java objects use `.equals()` for value comparison. TS `===` compares references for objects. Use deep-equal libraries or compare relevant fields explicitly. +- **Thread safety patterns are dead code**: Remove all `synchronized`, `volatile`, `Lock`, `Atomic*` patterns. JS is single-threaded. Keeping them adds confusion with zero benefit. +- **Builder pattern is often unnecessary**: Java builders exist because constructors can't have named parameters. TypeScript objects with optional properties serve the same purpose more concisely. +- **Over-engineering inheritance**: Java projects often have deep class hierarchies. In TypeScript, prefer composition and interfaces. Flatten hierarchies where possible — if a class exists only to share one method, use a utility function instead. +- **Lombok `@Data` on mutable classes**: If the Java class was mutable (setters used), decide whether TS version should be mutable too. Often the answer is no — make properties `readonly` and create new instances instead of mutating. +- **Lombok `@Builder.Default`**: Default values in Lombok builders (`@Builder.Default private boolean unfurlLinks = true`) must become explicit defaults in the TS constructor destructuring: `{ unfurlLinks = true }: Options`. +- **`CompletableFuture.join()` blocks the thread**: There is NO blocking equivalent in JS. `await` is non-blocking. Code that uses `join()` for synchronous access must be redesigned to be fully async. +- **`ExecutorService` thread pools**: Remove entirely. JS is single-threaded. `Promise.all()` provides concurrency for I/O-bound work without thread management. For CPU-bound work, use worker threads only if profiling shows a bottleneck. +- **`@FunctionalInterface` with checked exceptions**: Java functional interfaces can declare `throws Exception`. TypeScript function types cannot. Async functions that reject should document their error types in JSDoc but cannot enforce catching at the type level. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/classes.html -- TS classes and inheritance +- https://www.typescriptlang.org/docs/handbook/2/generics.html -- TS generics +- https://www.typescriptlang.org/docs/handbook/decorators.html -- TS decorators (annotation equivalent) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map -- JS Map (HashMap equivalent) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise -- Promise (Future equivalent) + +## instructions + +Use this expert when rewriting Java source code in TypeScript. Start by identifying the Java patterns in use (class hierarchies, generics, annotations, Lombok annotations, Stream API, CompletableFuture chains, functional interfaces, concurrency, Optional) and map each to its TS equivalent. Focus on simplification: flatten unnecessary class hierarchies, replace Lombok @Data/@Builder with interfaces and options objects, remove builder patterns in favor of typed options objects, rewrite CompletableFuture chains as async/await, convert @FunctionalInterface to type aliases, eliminate synchronization code, and convert getters/setters to direct property access. Pair with `dependency-mapping-ts.md` for Maven/Gradle → npm equivalents, `type-mapping-ts.md` for cross-language type reference, and `json-serialization-ts.md` for Gson/Jackson serialization conversion. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Java to TypeScript. Cover: class hierarchies to interfaces/classes, generics mapping, annotations to decorators, Stream API to array methods, Optional to nullable types, checked exceptions removal, synchronized/volatile removal, enum with behavior to const objects, getter/setter simplification, builder pattern elimination, and package structure flattening. Include 3 worked examples." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/js-to-ts-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/js-to-ts-ts.md new file mode 100644 index 000000000..3ac40a4b3 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/js-to-ts-ts.md @@ -0,0 +1,131 @@ +# js-to-ts-ts + +## purpose + +Converting JavaScript source files to idiomatic TypeScript — adding type annotations, modernizing module syntax, configuring strict compilation, and handling untyped dependencies. + +## rules + +1. Rename `.js` files to `.ts` (or `.tsx` for JSX). This is the first mechanical step — TypeScript compiles `.ts` files and ignores `.js` by default unless `allowJs` is set. +2. Convert `require()`/`module.exports` to ESM `import`/`export`. `const x = require('y')` becomes `import x from 'y'` (default) or `import { x } from 'y'` (named). `module.exports = { a, b }` becomes `export { a, b }`. +3. Enable `"strict": true` in `tsconfig.json` from the start. Fixing strict errors during conversion is far easier than enabling strict later and facing hundreds of errors at once. +4. Prefer `interface` over `type` for object shapes — interfaces are extensible and produce better error messages. Use `type` for unions, intersections, and mapped types. +5. Replace `/** @type {X} */` JSDoc annotations with inline TypeScript annotations. JSDoc types are redundant once the file is `.ts`. +6. Add explicit return types to exported functions. Internal/private functions can rely on inference, but public API boundaries should have declared types for documentation and refactor safety. +7. Replace `any` with specific types. When the real type is unknown, prefer `unknown` and narrow with type guards. Use `any` only as a temporary escape hatch, marked with `// TODO: type this`. +8. For untyped npm dependencies, install `@types/{package}` from DefinitelyTyped. If no `@types` package exists, create a minimal `declarations.d.ts` with `declare module '{package}'`. +9. Convert dynamic property access patterns (`obj[key]`) to use `Record` or an index signature. Slack bots frequently use `payload[field]` patterns that need explicit typing. +10. Replace `arguments` object usage with rest parameters (`...args: T[]`). Replace `Function.prototype.apply/call` patterns with direct invocation or spread syntax. +11. Convert `var` to `const`/`let`. Prefer `const` unless reassignment is needed. +12. Add `as const` assertions to literal objects and arrays that should not be widened (e.g., configuration objects, route tables). + +## patterns + +### require/module.exports → ESM import/export + +```javascript +// --- Before (JS) --- +const express = require('express'); +const { WebClient } = require('@slack/web-api'); +const config = require('./config'); + +function createApp(port) { + const app = express(); + app.listen(port); + return app; +} + +module.exports = { createApp }; +``` + +```typescript +// --- After (TS) --- +import express from 'express'; +import { WebClient } from '@slack/web-api'; +import config from './config.js'; + +function createApp(port: number): express.Application { + const app = express(); + app.listen(port); + return app; +} + +export { createApp }; +``` + +### Typing callback-heavy patterns + +```javascript +// --- Before (JS) --- +function fetchData(url, callback) { + fetch(url) + .then(res => res.json()) + .then(data => callback(null, data)) + .catch(err => callback(err, null)); +} +``` + +```typescript +// --- After (TS) --- +interface FetchResult { + data: T; + status: number; +} + +async function fetchData(url: string): Promise> { + const res = await fetch(url); + const data: T = await res.json(); + return { data, status: res.status }; +} +``` + +### Starter tsconfig.json for conversion projects + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## pitfalls + +- **`esModuleInterop` required for CJS default imports**: Without it, `import express from 'express'` fails for CommonJS packages. Always enable `esModuleInterop: true`. +- **JSON imports need `resolveJsonModule`**: JS code that does `require('./data.json')` won't work in TS without `resolveJsonModule: true` in tsconfig. +- **Implicit `any` in callbacks**: Event handler callbacks like `app.on('data', (msg) => ...)` often infer `any` for parameters. Add explicit types: `(msg: IncomingMessage) => ...`. +- **`this` context in class methods**: JS classes using `this` in callbacks lose context. Use arrow functions or add explicit `this` parameter types. +- **Optional chaining vs truthy checks**: JS code like `if (obj && obj.prop)` can become `obj?.prop` in TS, but be careful with falsy values (`0`, `""`, `false`) — optional chaining only checks `null`/`undefined`. +- **Enum vs union**: Don't reflexively convert string constants to `enum`. Prefer string literal unions (`type Status = 'active' | 'inactive'`) unless you need reverse mapping. +- **Missing `@types` packages**: Not all npm packages have types. Check with `npm info @types/{package}` before creating manual declarations. +- **`export default` vs `export =`**: Some CJS modules use `export = X` in their type definitions. Import these with `import X from 'module'` (with `esModuleInterop`) not `import { X }`. + +## references + +- https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html +- https://www.typescriptlang.org/tsconfig -- tsconfig reference +- https://github.com/DefinitelyTyped/DefinitelyTyped -- @types packages +- https://www.typescriptlang.org/docs/handbook/2/types-from-types.html -- utility types + +## instructions + +Use this expert when converting JavaScript source files to TypeScript. Start by renaming files and converting module syntax, then progressively add types starting from the public API surface inward. Pair with `type-mapping-ts.md` for cross-language type reference and `dependency-mapping-ts.md` if the JS project uses packages that need TS-typed alternatives. + +## research + +Deep Research prompt: + +"Write a micro expert on converting JavaScript to TypeScript. Cover: require/module.exports to ESM imports, tsconfig strict mode setup, typing callback patterns, handling untyped dependencies with @types and declaration files, common JS idioms that need TS adaptation (var, arguments, dynamic property access), and a starter tsconfig.json for conversion projects." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/json-serialization-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/json-serialization-ts.md new file mode 100644 index 000000000..5e2b18c44 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/json-serialization-ts.md @@ -0,0 +1,223 @@ +# json-serialization-ts + +## purpose + +Converting Java Gson/Jackson JSON serialization patterns to TypeScript — replacing custom serializers, `@SerializedName` annotations, polymorphic type factories, and schema validation with native `JSON.parse()`/`JSON.stringify()`, Zod schemas, and discriminated unions. + +## rules + +1. Java's Gson/Jackson are replaced by the built-in `JSON.parse()` and `JSON.stringify()`. No library needed for basic serialization. Add `zod` only when you need runtime schema validation (external API responses, user input). +2. Gson `@SerializedName("snake_case")` on Java fields maps to a Zod schema with `.transform()` for renaming, or simply use the snake_case keys directly in the TypeScript interface if the JSON wire format is snake_case (common in Slack APIs). +3. **Do NOT rename JSON fields to camelCase in the data layer.** If the API sends `thread_ts`, keep `thread_ts` in your interface. Only convert to camelCase at the application boundary if needed. This avoids serialization bugs and keeps types aligned with API docs. +4. Gson `TypeAdapter` / Jackson `@JsonTypeInfo` + `@JsonSubTypes` for polymorphic deserialization → TypeScript discriminated unions with a `type` field + Zod `z.discriminatedUnion()`. This is the most important pattern for Block Kit model conversion. +5. Gson `GsonBuilder().registerTypeAdapterFactory()` for a family of types → a single Zod discriminated union schema that handles all variants. No factory registration needed — Zod validates and narrows in one step. +6. Java `Date`/`Instant` serialized as epoch seconds or ISO strings → parse with `new Date(epoch * 1000)` or `new Date(isoString)`. Use `z.coerce.date()` in Zod for automatic string-to-Date conversion. +7. Gson `@Expose` / Jackson `@JsonIgnore` for selective serialization → TypeScript `Omit` utility type at the serialization boundary, or use a `toJSON()` method on classes. +8. Gson null handling (`serializeNulls()`) → JSON.stringify includes `null` by default but omits `undefined`. Use `null` (not `undefined`) for fields that must appear in the wire format. +9. Java `Map` deserialized as a catch-all → `z.record(z.string(), z.unknown())` for validated records, or `Record` for type-only. +10. Custom Gson deserializers that inspect JSON structure to decide the concrete type → Zod `.transform()` pipelines or preprocess functions that inspect the raw JSON before validating. +11. Jackson `@JsonCreator` / `@JsonProperty` constructor deserialization → just use Zod `.parse()` which returns a plain object matching the schema. No special constructor needed. +12. Large model hierarchies (like Slack's Block Kit: 15+ block types, 20+ element types) should use **one discriminated union per hierarchy level**, not one giant union. This keeps validation fast and error messages readable. + +## patterns + +### Gson @SerializedName → TypeScript interface with wire-format keys + +```java +// --- Before (Java with Gson) --- +public class SlackUser { + @SerializedName("user_id") + private String userId; + + @SerializedName("real_name") + private String realName; + + @SerializedName("is_admin") + private boolean isAdmin; + + @SerializedName("updated") + private long updatedTimestamp; + + // Gson auto-deserializes: {"user_id":"U123","real_name":"Alice","is_admin":true,"updated":1700000000} +} +``` + +```typescript +// --- After (TypeScript) --- +// Keep snake_case keys to match the API wire format +interface SlackUser { + user_id: string; + real_name: string; + is_admin: boolean; + updated: number; +} + +// Parse with no library — JSON.parse returns the right shape +const user: SlackUser = JSON.parse(responseBody); + +// With Zod for runtime validation (recommended for external API data): +import { z } from 'zod'; + +const SlackUserSchema = z.object({ + user_id: z.string(), + real_name: z.string(), + is_admin: z.boolean(), + updated: z.number(), +}); + +type SlackUser = z.infer; + +const user = SlackUserSchema.parse(JSON.parse(responseBody)); +``` + +### Gson polymorphic TypeAdapter → Zod discriminated union + +```java +// --- Before (Java with Gson) --- +// Custom factory for deserializing Block Kit blocks by "type" field +public class GsonLayoutBlockFactory implements JsonDeserializer { + @Override + public LayoutBlock deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx) { + String type = json.getAsJsonObject().get("type").getAsString(); + switch (type) { + case "section": return ctx.deserialize(json, SectionBlock.class); + case "actions": return ctx.deserialize(json, ActionsBlock.class); + case "divider": return ctx.deserialize(json, DividerBlock.class); + case "header": return ctx.deserialize(json, HeaderBlock.class); + case "image": return ctx.deserialize(json, ImageBlock.class); + case "context": return ctx.deserialize(json, ContextBlock.class); + case "input": return ctx.deserialize(json, InputBlock.class); + default: throw new JsonParseException("Unknown block type: " + type); + } + } +} + +// Registration: +Gson gson = new GsonBuilder() + .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory()) + .create(); + +List blocks = gson.fromJson(json, new TypeToken>(){}.getType()); +``` + +```typescript +// --- After (TypeScript with Zod) --- +import { z } from 'zod'; + +// Define each block variant schema +const SectionBlockSchema = z.object({ + type: z.literal('section'), + block_id: z.string().optional(), + text: z.object({ type: z.string(), text: z.string() }).optional(), + fields: z.array(z.object({ type: z.string(), text: z.string() })).optional(), + accessory: z.unknown().optional(), +}); + +const ActionsBlockSchema = z.object({ + type: z.literal('actions'), + block_id: z.string().optional(), + elements: z.array(z.unknown()), +}); + +const DividerBlockSchema = z.object({ + type: z.literal('divider'), + block_id: z.string().optional(), +}); + +const HeaderBlockSchema = z.object({ + type: z.literal('header'), + block_id: z.string().optional(), + text: z.object({ type: z.literal('plain_text'), text: z.string() }), +}); + +const ImageBlockSchema = z.object({ + type: z.literal('image'), + block_id: z.string().optional(), + image_url: z.string().url(), + alt_text: z.string(), +}); + +const ContextBlockSchema = z.object({ + type: z.literal('context'), + block_id: z.string().optional(), + elements: z.array(z.unknown()), +}); + +const InputBlockSchema = z.object({ + type: z.literal('input'), + block_id: z.string().optional(), + label: z.object({ type: z.literal('plain_text'), text: z.string() }), + element: z.unknown(), +}); + +// Discriminated union replaces the entire TypeAdapter factory +const LayoutBlockSchema = z.discriminatedUnion('type', [ + SectionBlockSchema, + ActionsBlockSchema, + DividerBlockSchema, + HeaderBlockSchema, + ImageBlockSchema, + ContextBlockSchema, + InputBlockSchema, +]); + +type LayoutBlock = z.infer; + +// Usage — replaces Gson.fromJson() + TypeToken +const blocks = z.array(LayoutBlockSchema).parse(JSON.parse(jsonString)); +// Each block is automatically narrowed by its `type` field +``` + +### Gson custom date handling → Zod coerce + +```java +// --- Before (Java) --- +// Custom Gson adapter for epoch seconds +GsonBuilder builder = new GsonBuilder(); +builder.registerTypeAdapter(Instant.class, (JsonDeserializer) (json, type, ctx) -> + Instant.ofEpochSecond(json.getAsLong())); +``` + +```typescript +// --- After (TypeScript with Zod) --- +const TimestampSchema = z.number().transform((epoch) => new Date(epoch * 1000)); + +// Or for ISO string dates: +const DateStringSchema = z.string().pipe(z.coerce.date()); + +// In a larger schema: +const EventSchema = z.object({ + type: z.string(), + event_ts: TimestampSchema, + created_at: DateStringSchema.optional(), +}); +``` + +## pitfalls + +- **Over-validating internal data**: Use Zod at system boundaries (API responses, webhook payloads, user input). Don't validate data you just created yourself — that's wasteful. +- **Renaming fields to camelCase**: Resist the urge to transform `thread_ts` to `threadTs` in the data model. Keep wire-format keys to avoid serialization/deserialization bugs and stay aligned with API docs. Transform at the UI/application boundary if needed. +- **Gson lenient mode**: Gson's `setLenient(true)` accepts malformed JSON. `JSON.parse()` is strict by default. If the source data is not strict JSON (trailing commas, single quotes), clean it before parsing. +- **Losing type narrowing**: Java's polymorphic deserialization returns the base type. TypeScript's discriminated unions + Zod automatically narrow to the specific variant. Leverage this — use `switch (block.type)` and TS will infer the variant type. +- **Huge union schemas**: A single `z.discriminatedUnion()` with 30+ variants is slow to compile and produces unreadable errors. Split into hierarchical unions: `LayoutBlock`, `BlockElement`, `TextObject`, etc. +- **`null` vs missing key**: Gson distinguishes between `"field": null` and absent `"field"`. In TS, both become `undefined` with `z.optional()`. Use `z.nullable()` if you need to distinguish null from missing. +- **Generic type tokens**: Java's `TypeToken>` for generic deserialization has no TS equivalent — it's not needed. `z.array(schema).parse(data)` handles generic arrays directly. +- **Circular references**: If Java models have circular references (A references B which references A), Gson handles this via lazy deserialization. Zod schemas can use `z.lazy()` for circular types, but redesign to break the cycle if possible. + +## references + +- https://zod.dev/ -- Zod schema validation library +- https://github.com/google/gson -- Gson (source library reference) +- https://github.com/FasterXML/jackson -- Jackson (source library reference) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON -- JSON built-in +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions -- discriminated unions + +## instructions + +Use this expert when converting Java Gson/Jackson serialization code to TypeScript. The most critical pattern is polymorphic deserialization (TypeAdapter factories → Zod discriminated unions), which affects all Block Kit model conversion. Start by identifying all `@SerializedName` fields and custom TypeAdapter/JsonDeserializer classes, then map them to TypeScript interfaces + Zod schemas. Pair with `java-to-ts-ts.md` for general Java→TS conversion, `type-mapping-ts.md` for type system reference, and `../bridge/ui-block-kit-adaptive-cards-ts.md` if converting Block Kit models to Adaptive Cards. + +## research + +Deep Research prompt: + +"Write a micro expert for converting Java Gson/Jackson JSON serialization to TypeScript. Cover: @SerializedName to interface fields, polymorphic TypeAdapter factories to Zod discriminated unions, custom deserializers to Zod transforms, date/timestamp handling, null semantics, TypeToken generics elimination, and large model hierarchy strategies. Include worked examples for a Block Kit-style type hierarchy with 7+ variants." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/kotlin-to-ts-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/kotlin-to-ts-ts.md new file mode 100644 index 000000000..000142d84 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/kotlin-to-ts-ts.md @@ -0,0 +1,212 @@ +# kotlin-to-ts-ts + +## purpose + +Rewriting Kotlin source code as idiomatic TypeScript — covering Kotlin-specific syntax (trailing lambdas, null-safety operators, string templates, `it`, `when`, extension functions) that the Java-to-TS expert does not address. + +## rules + +1. Kotlin string templates (`"Hello $name"`, `"text ${expr}"`) map directly to TypeScript template literals (`` `Hello ${name}` ``, `` `text ${expr}` ``). Multi-line strings with `.trimIndent()` become TS template literals with no call needed — TS template literals already preserve literal indentation. +2. Kotlin trailing lambda syntax (`app.command("/echo") { req, ctx -> ... }`) maps to a callback argument: `app.command("/echo", async (req, ctx) => { ... })`. The lambda body `{ ... }` becomes `async (...) => { ... }` when the target API is async. +3. Kotlin SAM (Single Abstract Method) conversions — where a lambda replaces a single-method interface — map to TypeScript function arguments directly. `app.event(handler)` where `handler` is a lambda becomes `app.on("event", async (ctx) => { ... })`. +4. Kotlin `it` (implicit single-parameter lambda) must be given an explicit name in TS. `list.filter { it.isActive }` becomes `list.filter((item) => item.isActive)`. Choose a meaningful name from context (`req`, `ctx`, `msg`, `user`, etc.). +5. Kotlin null-safety operator `?.` maps to TS optional chaining `?.`. Kotlin `!!` (non-null assertion) maps to TS `!` (non-null assertion). Kotlin elvis `?:` maps to TS nullish coalescing `??`. Examples: `user?.name` → `user?.name`, `value!!` → `value!`, `name ?: "default"` → `name ?? "default"`. +6. Kotlin `when` expressions map to TS `switch` statements or chained ternaries. `when` with no subject (boolean conditions) maps to `if/else if`. `when` with a subject (value matching) maps to `switch`. If used as an expression (assigned to a variable), prefer chained ternaries or an IIFE wrapping a `switch`. +7. Kotlin `val` maps to `const` (for locals) or `readonly` (for class fields). Kotlin `var` maps to `let`. Never use `var` in the TS output — always `const` or `let`. +8. Kotlin `fun` at package level (top-level functions) maps to TS exported functions: `export function myFn() { ... }`. Kotlin does not require a wrapping class for top-level functions, and neither does TS. +9. Kotlin extension functions (`fun String.toSlug(): String`) have no direct TS equivalent. Convert to a standalone utility function: `function toSlug(s: string): string`. If the extension is on a project type, consider adding a method to the class instead. +10. Kotlin `data class` maps to a TypeScript `interface` (for pure data) or a `class` with constructor shorthand (if methods are needed). `data class User(val name: String, val email: String)` → `interface User { readonly name: string; readonly email: string; }`. Destructuring `val (name, email) = user` → `const { name, email } = user`. +11. Kotlin `object` declarations (singletons) map to a plain TS module-level `const` object or a namespace. `object Config { val port = 3000 }` → `const Config = { port: 3000 } as const`. Kotlin `companion object` maps to `static` members on the class or module-level constants. +12. Kotlin `sealed class` / `sealed interface` maps to TS discriminated union types. `sealed class Result` with subclasses `Success` and `Error` → `type Result = { kind: 'success'; value: T } | { kind: 'error'; error: string }`. +13. Kotlin scope functions (`let`, `run`, `apply`, `also`, `with`) should be inlined rather than translated literally. `user?.let { sendEmail(it) }` → `if (user) sendEmail(user)`. `config.apply { port = 3000; host = "localhost" }` → direct property assignments. +14. Kotlin `listOf()`, `mapOf()`, `mutableListOf()`, `mutableMapOf()` map to TS array/object literals: `listOf("a", "b")` → `["a", "b"]`, `mapOf("key" to "value")` → `{ key: "value" }` or `new Map([["key", "value"]])`. +15. Kotlin `for (item in list)` maps to `for (const item of list)`. Kotlin ranges `for (i in 0 until n)` → `for (let i = 0; i < n; i++)`. Kotlin `for (i in 0..n)` (inclusive) → `for (let i = 0; i <= n; i++)`. +16. Kotlin type casts: `as` (unsafe cast) → TS `as` (type assertion). `as?` (safe cast) → TS has no direct equivalent; use a type guard function or conditional check. +17. Kotlin `::class.java` / `SomeClass::class.java` (class references for reflection) should be removed. In the Slack Bolt SDK, `app.event(AppMentionEvent::class.java) { ... }` becomes a string-based route: `app.on("message", async (ctx) => { ... })` in Teams. + +## patterns + +### Trailing lambda + ack pattern (Slack Bolt → Teams) + +```kotlin +// --- Before (Kotlin) --- +app.command("/echo") { req, ctx -> + val text = "You said ${req.payload.text} at <#${req.payload.channelId}|${req.payload.channelName}>" + ctx.respond { it.text(text) } + ctx.ack() +} +``` + +```typescript +// --- After (TypeScript, Teams SDK) --- +app.on('message', async ({ activity, send }) => { + const text = `You said ${activity.text}`; + await send(text); +}); +``` + +### Null-safety chain + +```kotlin +// --- Before (Kotlin) --- +val hash = event.event.view?.hash +val name = user?.profile?.displayName ?: "Unknown" +val id = data!!.userId +``` + +```typescript +// --- After (TypeScript) --- +const hash = event.event.view?.hash; +const name = user?.profile?.displayName ?? 'Unknown'; +const id = data!.userId; +``` + +### String templates + trimIndent + +```kotlin +// --- Before (Kotlin) --- +val view = """ +{ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Hello ${user.name}! Updated: ${ZonedDateTime.now()}" + } + } + ] +} +""".trimIndent() +``` + +```typescript +// --- After (TypeScript) --- +const view = { + type: 'home' as const, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `Hello ${user.name}! Updated: ${new Date().toISOString()}`, + }, + }, + ], +}; +// Prefer a typed object over a JSON string when the target SDK accepts objects. +// If a raw string is truly needed: +const viewJson = JSON.stringify(view); +``` + +### When expression → switch + +```kotlin +// --- Before (Kotlin) --- +val response = when (action) { + "approve" -> "Approved!" + "reject" -> "Rejected." + "defer" -> "Deferred to next week." + else -> "Unknown action: $action" +} +``` + +```typescript +// --- After (TypeScript) --- +let response: string; +switch (action) { + case 'approve': + response = 'Approved!'; + break; + case 'reject': + response = 'Rejected.'; + break; + case 'defer': + response = 'Deferred to next week.'; + break; + default: + response = `Unknown action: ${action}`; +} +``` + +### Scope function inlining + +```kotlin +// --- Before (Kotlin) --- +val result = config.apply { + port = 3000 + host = "localhost" +} + +user?.let { ctx.say("Hello ${it.name}") } + +val mapped = items.map { it.name to it.value }.toMap() +``` + +```typescript +// --- After (TypeScript) --- +const config = { port: 3000, host: 'localhost' }; + +if (user) { + await send(`Hello ${user.name}`); +} + +const mapped = Object.fromEntries(items.map((item) => [item.name, item.value])); +``` + +### Object declaration / companion object + +```kotlin +// --- Before (Kotlin) --- +class ResourceLoader { + companion object { + fun loadAppConfig(name: String = "appConfig.json"): AppConfig { + // ... + } + } +} +// Usage: ResourceLoader.loadAppConfig() +``` + +```typescript +// --- After (TypeScript) --- +// Companion object → module-level function (no class wrapper needed) +export function loadAppConfig(name = 'appConfig.json'): AppConfig { + // ... +} +// Usage: loadAppConfig() +``` + +## pitfalls + +- **Forgetting to name `it`**: Every Kotlin `it` reference must get an explicit TS parameter name. Blindly searching for `it` will produce false positives on English words — search for `{ it.` and `{ it ->` patterns. +- **`trimIndent()` on JSON strings**: Kotlin examples often build JSON as `.trimIndent()` multiline strings. In TS, prefer a typed object literal instead of a string. If the target API needs a string, use `JSON.stringify(obj)` for safety over manual template literals. +- **`!!` overuse**: Kotlin's `!!` means "throw if null". TS's `!` is only a compile-time assertion — it does NOT throw at runtime. If the Kotlin code relies on `!!` for runtime safety, add an explicit null check instead. +- **`as?` safe cast**: Kotlin's `as?` returns `null` if the cast fails. TS's `as` never fails at runtime (it's a compile-time assertion). Translate `as?` to a type guard check, not a bare `as`. +- **Trailing lambda position**: Kotlin allows the last lambda argument to be outside the parentheses. In TS, ALL arguments go inside the parentheses. `app.command("/echo") { req, ctx -> }` → `app.command("/echo", async (req, ctx) => { })`. +- **`listOf()` / `mapOf()` immutability**: Kotlin's `listOf()` returns an immutable list. TS arrays are mutable by default. If immutability matters, use `as const` or `ReadonlyArray`. +- **Class reference syntax**: `SomeClass::class.java` in Kotlin (used for event type registration in Slack Bolt) has no TS equivalent. Replace with the string event name expected by the target SDK. +- **Extension functions on primitives**: Kotlin can extend `String`, `Int`, etc. TS cannot extend primitive types. Always convert to standalone functions. +- **Destructuring data classes**: Kotlin `val (a, b) = pair` uses `componentN()` functions. TS destructuring uses property names: `const { first, second } = pair`. The names must match. + +## references + +- https://kotlinlang.org/docs/basic-syntax.html — Kotlin syntax reference +- https://kotlinlang.org/docs/null-safety.html — Kotlin null-safety operators +- https://kotlinlang.org/docs/lambdas.html — Kotlin lambda syntax and SAM conversions +- https://kotlinlang.org/docs/scope-functions.html — let, run, apply, also, with +- https://kotlinlang.org/docs/data-classes.html — Data classes +- https://kotlinlang.org/docs/extensions.html — Extension functions +- https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html — TS template literals +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html — TS type narrowing and guards + +## instructions + +Use this expert when the source code is Kotlin (`.kt` files). It handles Kotlin-specific syntax that the `java-to-ts-ts.md` expert does not cover: trailing lambdas, `it` implicit parameters, string templates, `trimIndent()`, null-safety operators (`?.`, `!!`, `?:`), `when` expressions, scope functions, extension functions, `data class`, `object`/`companion object`, sealed classes, and `::class.java` references. For Java SDK types, generics, collections, and Lombok patterns, pair with `java-to-ts-ts.md`. For type system mapping, pair with `type-mapping-ts.md`. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Kotlin to TypeScript. Cover: string templates to template literals, trailing lambda syntax to callback arguments, SAM conversions, it implicit parameter, null-safety operators (?. !! ?:) to optional chaining/nullish coalescing/non-null assertion, when expressions to switch, val/var to const/let, extension functions to utility functions, data class to interface, object declarations to module constants, sealed class to discriminated unions, scope functions (let/run/apply/also/with) inlining, and class reference syntax removal. Include 4-5 worked side-by-side examples." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/ruby-to-ts-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/ruby-to-ts-ts.md new file mode 100644 index 000000000..2cbf20778 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/ruby-to-ts-ts.md @@ -0,0 +1,224 @@ +# ruby-to-ts-ts + +## purpose + +Rewriting Ruby source code as idiomatic TypeScript — mapping Ruby language constructs, OOP patterns, metaprogramming, and common idioms to their TypeScript equivalents. + +## rules + +1. Ruby blocks (`do...end` / `{ |x| }`) map to arrow functions. `array.each { |item| puts item }` becomes `array.forEach((item) => console.log(item))`. Ruby's `yield` inside methods maps to calling a callback parameter. +2. Ruby mixins (`include Module`) map to TypeScript interfaces + composition. Do NOT use class inheritance to simulate mixins — use interface implementation with helper functions or the mixin pattern (`applyMixins`). +3. Ruby duck typing maps to TypeScript structural typing. If Ruby code checks `obj.respond_to?(:method)`, define an interface with that method and use a type guard: `function hasMethod(obj: unknown): obj is HasMethod`. +4. Ruby `attr_accessor :name` maps to a class property with TypeScript accessor shorthand: `constructor(public name: string) {}` or explicit `get`/`set` pairs if logic is needed. +5. Ruby symbols (`:name`) map to string literal types or enum members. A method accepting `type: :admin | :user` becomes `type: 'admin' | 'user'` in TypeScript. +6. Ruby hashes (`{ key: value }`) map to TypeScript objects or `Record`. Named-parameter hashes (`def method(opts = {})`) become destructured typed parameters: `function method({ key1, key2 }: Options)`. +7. Ruby `nil` maps to `null` or `undefined`. Use `null` for explicit absence and `undefined` for optional/missing. Ruby's `&.` safe navigator maps to optional chaining (`?.`). +8. Ruby `begin/rescue/ensure` maps to `try/catch/finally`. Ruby's typed rescue (`rescue TypeError => e`) maps to catching and narrowing: `catch (e) { if (e instanceof TypeError) ... }`. +9. Ruby open classes and monkey-patching have NO TypeScript equivalent. Redesign as wrapper functions, decorator patterns, or module augmentation (`declare module` for extending third-party types). +10. Ruby metaprogramming (`define_method`, `method_missing`, `send`) has no direct equivalent. Replace `define_method` loops with computed property patterns or factory functions. Replace `method_missing` with `Proxy` objects (sparingly) or explicit handler maps. +11. Ruby's `Enumerable` methods map to JavaScript array methods: `map`→`map`, `select`→`filter`, `reject`→`filter` (inverted), `reduce`→`reduce`, `detect`/`find`→`find`, `flat_map`→`flatMap`, `each_with_object`→`reduce`, `group_by`→custom `groupBy` or `Object.groupBy()`. +12. Ruby string interpolation `"Hello #{name}"` maps to template literals `` `Hello ${name}` ``. +13. Ruby `Proc.new` / `lambda` / `->` all map to arrow functions. Ruby's distinction between procs and lambdas (arity checking, return behavior) disappears — TypeScript arrow functions always behave like Ruby lambdas. +14. Ruby modules used as namespaces map to TypeScript modules (files) with named exports. Do NOT use TypeScript `namespace` keyword — use ES module `export` instead. + +## patterns + +### Ruby class with mixins → TypeScript interface + composition + +```ruby +# --- Before (Ruby) --- +module Greetable + def greet + "Hello, I'm #{name}" + end +end + +module Trackable + def track(event) + puts "Tracking #{event} for #{name}" + end +end + +class User + include Greetable + include Trackable + + attr_accessor :name, :email + + def initialize(name, email) + @name = name + @email = email + end +end + +user = User.new("Alice", "alice@example.com") +puts user.greet +user.track("login") +``` + +```typescript +// --- After (TypeScript) --- +interface Greetable { + name: string; + greet(): string; +} + +function greetMixin(obj: T): T & Greetable { + return Object.assign(obj, { + greet() { + return `Hello, I'm ${obj.name}`; + }, + }); +} + +interface Trackable { + name: string; + track(event: string): void; +} + +function trackMixin(obj: T): T & Trackable { + return Object.assign(obj, { + track(event: string) { + console.log(`Tracking ${event} for ${obj.name}`); + }, + }); +} + +class User { + constructor( + public name: string, + public email: string, + ) {} +} + +// Apply mixins +function createUser(name: string, email: string): User & Greetable & Trackable { + const user = new User(name, email); + return trackMixin(greetMixin(user)); +} + +const user = createUser("Alice", "alice@example.com"); +console.log(user.greet()); +user.track("login"); +``` + +### Ruby hash options / keyword args → TypeScript typed parameters + +```ruby +# --- Before (Ruby) --- +class SlackNotifier + def initialize(opts = {}) + @webhook_url = opts[:webhook_url] || ENV['SLACK_WEBHOOK'] + @channel = opts[:channel] || '#general' + @username = opts[:username] || 'bot' + end + + def notify(message, opts = {}) + icon = opts.fetch(:icon_emoji, ':robot_face:') + thread_ts = opts[:thread_ts] + # ... send notification + end +end + +notifier = SlackNotifier.new(webhook_url: 'https://...', channel: '#alerts') +notifier.notify('Deploy complete', icon_emoji: ':rocket:') +``` + +```typescript +// --- After (TypeScript) --- +interface SlackNotifierOptions { + webhookUrl?: string; + channel?: string; + username?: string; +} + +interface NotifyOptions { + iconEmoji?: string; + threadTs?: string; +} + +class SlackNotifier { + private readonly webhookUrl: string; + private readonly channel: string; + private readonly username: string; + + constructor({ + webhookUrl = process.env.SLACK_WEBHOOK ?? '', + channel = '#general', + username = 'bot', + }: SlackNotifierOptions = {}) { + this.webhookUrl = webhookUrl; + this.channel = channel; + this.username = username; + } + + notify(message: string, { iconEmoji = ':robot_face:', threadTs }: NotifyOptions = {}): void { + // ... send notification + } +} + +const notifier = new SlackNotifier({ webhookUrl: 'https://...', channel: '#alerts' }); +notifier.notify('Deploy complete', { iconEmoji: ':rocket:' }); +``` + +### Ruby Enumerable → TypeScript array methods + +```ruby +# --- Before (Ruby) --- +users = get_users() +active_admins = users + .select { |u| u.active? } + .reject { |u| u.guest? } + .select { |u| u.role == :admin } + .map { |u| { name: u.name, email: u.email } } + .sort_by { |h| h[:name] } +``` + +```typescript +// --- After (TypeScript) --- +interface User { + name: string; + email: string; + active: boolean; + guest: boolean; + role: 'admin' | 'user' | 'guest'; +} + +const users: User[] = getUsers(); +const activeAdmins = users + .filter((u) => u.active) + .filter((u) => !u.guest) + .filter((u) => u.role === 'admin') + .map((u) => ({ name: u.name, email: u.email })) + .sort((a, b) => a.name.localeCompare(b.name)); +``` + +## pitfalls + +- **Ruby truthiness vs JS truthiness**: In Ruby, only `nil` and `false` are falsy. In JS/TS, `0`, `""`, `NaN`, `null`, `undefined`, and `false` are all falsy. Ruby code like `if count` (truthy when 0) must become `if (count !== null && count !== undefined)` in TS. +- **Ruby `==` is value equality; JS `===` is identity for objects**: Ruby `==` on strings/numbers compares values. TS `===` on primitives works the same, but on objects it compares references. Deep equality requires a library or custom check. +- **`each` return value**: Ruby's `each` returns the original array. JS `forEach` returns `undefined`. Don't chain after `forEach`. +- **Ruby ranges (`1..10`)**: No TS equivalent. Use `Array.from({ length: 10 }, (_, i) => i + 1)` or a simple `for` loop. +- **String is mutable in Ruby, immutable in JS**: Ruby `str.gsub!` mutates in place. TS strings are immutable — always reassign: `str = str.replace(...)`. +- **Ruby exception hierarchy**: Ruby has `StandardError`, `RuntimeError`, etc. TS/JS only has `Error`. Use custom error classes extending `Error` if you need a hierarchy. +- **Snake_case to camelCase**: Ruby uses `snake_case` for methods/variables. TypeScript convention is `camelCase`. Convert all identifiers, but keep API payloads in their original format (e.g., Slack payloads use `snake_case`). +- **Ruby `require` is file-level, not scoped**: All Ruby `require` statements load globally. TS `import` is scoped to the file. This means Ruby's implicit global availability must become explicit imports in every file that uses the dependency. +- **Sinatra/Rack → Express**: Ruby Sinatra routes (`get '/' do ... end`) map to Express (`app.get('/', (req, res) => { ... })`). The middleware patterns are similar but request/response APIs differ completely. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/classes.html -- TS classes +- https://www.typescriptlang.org/docs/handbook/2/objects.html -- structural typing +- https://www.typescriptlang.org/docs/handbook/mixins.html -- mixin pattern +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array -- JS array methods (Enumerable equivalents) +- https://ruby-doc.org/core/Enumerable.html -- Ruby Enumerable reference (for mapping) + +## instructions + +Use this expert when rewriting Ruby source code in TypeScript. Start by identifying the Ruby constructs in use (classes, modules/mixins, blocks, metaprogramming, Enumerable chains) and map each to its TS equivalent using the rules above. Pay special attention to truthiness differences, mixin patterns, and naming convention changes (snake_case → camelCase). Pair with `dependency-mapping-ts.md` for gem → npm package equivalents, and `type-mapping-ts.md` for cross-language type reference. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Ruby to TypeScript. Cover: blocks to arrow functions, mixins to interfaces/composition, duck typing to structural typing, attr_accessor to class properties, symbol to string literals, hash options to typed parameters, metaprogramming alternatives, Enumerable methods to array methods, exception handling, truthiness differences, and naming convention conversion (snake_case to camelCase). Include 3 worked examples." diff --git a/skills/microsoft-365-agents-toolkit/experts/convert/type-mapping-ts.md b/skills/microsoft-365-agents-toolkit/experts/convert/type-mapping-ts.md new file mode 100644 index 000000000..89aab967c --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/convert/type-mapping-ts.md @@ -0,0 +1,214 @@ +# type-mapping-ts + +## purpose + +Cross-language type system mapping reference — translating type concepts from JavaScript, Ruby, and Java into idiomatic TypeScript types, covering primitives, nullability, generics, collections, enums, and structural patterns. + +## rules + +1. Map primitive types using the canonical table below. TypeScript uses lowercase for primitives (`string`, `number`, `boolean`) — never use the wrapper types (`String`, `Number`, `Boolean`). +2. Nullable types: Java `@Nullable T` and Ruby's implicit nil-ability both map to `T | null`. For optional parameters/properties, use `T | undefined` (or the `?` optional marker). Distinguish between "explicitly null" and "not provided". +3. Generic type parameters use the same `` syntax across Java and TypeScript. Ruby has no generics — infer types from usage patterns and add explicit generic parameters during conversion. +4. Collection types: Java `List` → `T[]`, Java `Map` → `Map` or `Record`, Java `Set` → `Set`. Ruby `Array` → `T[]`, Ruby `Hash` → `Record` or `Map`. +5. Union types (`A | B`) are TypeScript's killer feature with no direct Java or Ruby equivalent. Use them liberally to replace: Java method overloading, Ruby duck-typed parameters that accept multiple types, and stringly-typed fields. +6. Discriminated unions replace Java's visitor pattern and Ruby's case-when on class type. Add a `type` or `kind` literal field to each variant for exhaustive narrowing. +7. TypeScript `unknown` is safer than `any`. Use `unknown` for values from external sources (API responses, user input, parsed JSON) and narrow with type guards. Reserve `any` for temporary migration scaffolding. +8. Ruby symbols (`:name`) and Java string constants (`public static final String`) both map to string literal types: `type Role = 'admin' | 'user' | 'guest'`. +9. Java `void` maps to TypeScript `void`. Ruby methods that return `nil` implicitly map to `void` return type (or `T | undefined` if the nil return is meaningful). +10. Tuple types (`[string, number]`) are useful when converting Ruby methods that return multiple values (`return name, age`) or Java `Pair` / `Map.Entry`. +11. Use `readonly` modifier for properties that were `final` in Java or `freeze`-d in Ruby. Use `Readonly` utility type for deeply immutable objects. +12. Index signatures (`[key: string]: T`) replace Java's `Map` and Ruby's open hashes when the key set is not known at compile time. + +## patterns + +### Primitive type mapping table + +| Concept | Java | Ruby | JavaScript | TypeScript | +|---|---|---|---|---| +| String | `String` | `String` | `string` | `string` | +| Integer | `int` / `Integer` | `Integer` / `Fixnum` | `number` | `number` | +| Float | `double` / `Double` / `float` | `Float` | `number` | `number` | +| Big integer | `BigInteger` / `long` | `Bignum` | `bigint` | `bigint` | +| Boolean | `boolean` / `Boolean` | `TrueClass`/`FalseClass` | `boolean` | `boolean` | +| Null | `null` | `nil` | `null` | `null` | +| Undefined | N/A | N/A | `undefined` | `undefined` | +| Void | `void` | implicit nil | `undefined` | `void` | +| Any/Object | `Object` | `Object` | `any` | `unknown` (preferred) or `any` | +| Byte array | `byte[]` | `String` (binary) | `Uint8Array` | `Uint8Array` or `Buffer` | +| Date/Time | `LocalDateTime` / `Instant` | `Time` / `DateTime` | `Date` | `Date` or `Temporal` (stage 3) | +| Regex | `Pattern` | `Regexp` | `RegExp` | `RegExp` | +| Symbol | N/A | `Symbol` (`:name`) | `symbol` / string | string literal type | + +### Collection type mapping table + +| Concept | Java | Ruby | TypeScript | +|---|---|---|---| +| Ordered list | `List` / `ArrayList` | `Array` | `T[]` or `Array` | +| Fixed-size tuple | `Pair` / `record` (Java 16+) | `[a, b]` array | `[A, B]` tuple | +| Key-value map (string keys) | `Map` | `Hash` | `Record` | +| Key-value map (any keys) | `Map` | `Hash` | `Map` | +| Unique set | `Set` / `HashSet` | `Set` | `Set` | +| Queue | `Queue` / `Deque` | `Array` (push/shift) | `T[]` (push/shift) | +| Immutable list | `List.of()` / `Collections.unmodifiable` | `freeze` | `readonly T[]` or `ReadonlyArray` | +| Immutable map | `Map.of()` | `freeze` | `Readonly>` | + +### Nullability pattern mapping + +```typescript +// Java Optional → TypeScript +// Java: Optional findUser(String id) +// Ruby: def find_user(id) → User or nil +// TS: +function findUser(id: string): User | null { + const user = db.get(id); + return user ?? null; +} + +// Java Optional chain → TypeScript optional chaining +// Java: user.flatMap(u -> u.getAddress()).map(a -> a.getCity()).orElse("Unknown") +// Ruby: user&.address&.city || "Unknown" +// TS: +const city = user?.address?.city ?? 'Unknown'; + +// Java @Nullable parameter → TypeScript optional parameter +// Java: void send(String msg, @Nullable String channel) +// Ruby: def send(msg, channel = nil) +// TS: +function send(msg: string, channel?: string): void { + const target = channel ?? '#general'; + // ... +} +``` + +### Discriminated union (replaces Java visitor / Ruby case-when on type) + +```java +// --- Java (before) --- +// Visitor pattern with 3 message types +public interface MessageVisitor { + void visit(TextMessage msg); + void visit(CardMessage msg); + void visit(FileMessage msg); +} + +public abstract class Message { + public abstract void accept(MessageVisitor visitor); +} +``` + +```ruby +# --- Ruby (before) --- +# Case-when on class type +case message +when TextMessage + handle_text(message) +when CardMessage + handle_card(message) +when FileMessage + handle_file(message) +end +``` + +```typescript +// --- TypeScript (after) --- +// Discriminated union replaces both patterns +interface TextMessage { + kind: 'text'; + content: string; +} + +interface CardMessage { + kind: 'card'; + cardJson: Record; +} + +interface FileMessage { + kind: 'file'; + url: string; + mimeType: string; +} + +type Message = TextMessage | CardMessage | FileMessage; + +function handleMessage(msg: Message): void { + switch (msg.kind) { + case 'text': + console.log(msg.content); // TS narrows to TextMessage + break; + case 'card': + renderCard(msg.cardJson); // TS narrows to CardMessage + break; + case 'file': + downloadFile(msg.url); // TS narrows to FileMessage + break; + } + // Exhaustive — adding a new variant causes a compile error +} +``` + +### Generics mapping + +```java +// --- Java (before) --- +public class Repository { + private final Map store = new HashMap<>(); + + public Optional findById(String id) { + return Optional.ofNullable(store.get(id)); + } + + public List findAll(Predicate filter) { + return store.values().stream() + .filter(filter) + .collect(Collectors.toList()); + } +} +``` + +```typescript +// --- TypeScript (after) --- +interface Entity { + id: string; +} + +class Repository { + private readonly store = new Map(); + + findById(id: string): T | null { + return this.store.get(id) ?? null; + } + + findAll(filter: (item: T) => boolean): T[] { + return [...this.store.values()].filter(filter); + } +} +``` + +## pitfalls + +- **`number` covers both int and float**: TypeScript has no integer type. If integer precision matters (IDs, counters), document the expectation or use `bigint` for very large values. +- **`null` vs `undefined` confusion**: Pick a convention. Recommendation: `undefined` for "optional/missing" (function params, object properties), `null` for "explicitly empty" (API responses, database NULLs). +- **Wrapper types**: Never use `String`, `Number`, `Boolean` as types in TypeScript. Always use lowercase `string`, `number`, `boolean`. +- **Java `int` overflow**: Java `int` is 32-bit; TypeScript `number` is 64-bit float. Values above `Number.MAX_SAFE_INTEGER` (2^53-1) lose precision. Use `bigint` if the Java code relies on exact large integer arithmetic. +- **Ruby's open type system**: Ruby allows adding methods to any object at runtime. TypeScript's type system is closed at compile time. Methods discovered via `method_missing` or `define_method` must be predefined in interfaces. +- **Enum pitfalls**: TypeScript numeric enums have reverse mapping (`Priority[1] === 'HIGH'`), which is usually unexpected. Prefer string literal unions or `as const` objects. +- **Generic variance**: Java has `? extends T` (covariant) and `? super T` (contravariant). TypeScript uses structural subtyping and generally infers variance. Explicit variance annotations (`in`/`out` modifiers) exist but are rarely needed. +- **Date handling**: Java's `java.time` and Ruby's `Time`/`DateTime` are far richer than JS `Date`. For serious date work, use `date-fns` or `luxon` rather than relying on the built-in `Date`. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/everyday-types.html -- basic types +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html -- type narrowing and guards +- https://www.typescriptlang.org/docs/handbook/2/generics.html -- generics +- https://www.typescriptlang.org/docs/handbook/utility-types.html -- Readonly, Partial, Pick, etc. +- https://www.typescriptlang.org/docs/handbook/2/types-from-types.html -- advanced type construction + +## instructions + +Use this expert as a cross-language type reference when converting from any source language to TypeScript. Consult the primitive and collection mapping tables first, then use the nullability and generics patterns for complex type scenarios. This expert is a dependency of all three language-specific conversion experts — they reference it for type translation questions. Pair with the appropriate language expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `java-to-ts-ts.md`) for language-specific idiom conversion beyond types. + +## research + +Deep Research prompt: + +"Write a micro expert for cross-language type mapping to TypeScript. Cover: primitive type mapping from Java/Ruby/JS to TS, collection type mapping (List, Map, Set, Queue), nullability patterns (Optional, nil, null/undefined), generic type parameter translation, discriminated unions replacing visitor/case-when patterns, enum mapping strategies, and common type system pitfalls when converting from statically-typed (Java) and dynamically-typed (Ruby/JS) languages." diff --git a/skills/microsoft-365-agents-toolkit/experts/deploy/aws-bot-deploy-ts.md b/skills/microsoft-365-agents-toolkit/experts/deploy/aws-bot-deploy-ts.md new file mode 100644 index 000000000..c665d944b --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/deploy/aws-bot-deploy-ts.md @@ -0,0 +1,263 @@ +# aws-bot-deploy-ts + +## purpose + +Step-by-step deployment of a Slack bot or Teams bot to AWS. Covers AWS CLI setup, IAM configuration, compute provisioning (Lambda + API Gateway / EC2 / ECS Fargate), environment configuration, and verification. Teams bots on AWS still require an Azure Bot Service registration for the Bot Framework messaging endpoint. + +## rules + +1. **Install prerequisites.** You need: Node.js 20 LTS, AWS CLI v2, and optionally AWS SAM CLI (`pip install aws-sam-cli`) or AWS CDK (`npm install -g aws-cdk`). Verify with `aws --version` and `node --version`. [docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) +2. **Configure AWS credentials.** Run `aws configure` and enter your IAM access key, secret key, default region, and output format. For SSO-enabled organizations, use `aws sso login` instead. Verify with `aws sts get-caller-identity`. [docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) +3. **Create an IAM user or role for the bot.** The bot's execution role needs permissions for: CloudWatch Logs (logging), Secrets Manager or SSM Parameter Store (credentials), and any other AWS services it accesses. Use least-privilege — don't give the bot AdministratorAccess. [docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) +4. **Create a Slack API app at api.slack.com.** Under "OAuth & Permissions", install the app to your workspace and copy the Bot User OAuth Token (`xoxb-...`). Under "Basic Information", copy the Signing Secret. For Socket Mode, also create an App-Level Token (`xapp-...`). [api.slack.com/authentication/basics](https://api.slack.com/authentication/basics) +5. **Choose your compute target.** Lambda + API Gateway (serverless, event-driven — best for HTTP-mode Slack bots), EC2 or Elastic Beanstalk (always-on — required for Socket Mode, good for Teams bots), or ECS Fargate (containerized, production-grade). [docs.aws.amazon.com/lambda/latest/dg/welcome.html](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) +6. **For Lambda: use SAM or CDK to define the stack.** A SAM template defines the Lambda function + API Gateway in YAML. `sam build && sam deploy --guided` handles packaging, uploading, and CloudFormation stack creation. [docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) +7. **For Lambda: handle the Slack 3-second ack deadline.** Lambda cold starts can take 1-5 seconds. Use provisioned concurrency (`ProvisionedConcurrencyConfig` in SAM) to keep warm instances, or use the async pattern: immediately return 200 to ack, then process via SQS + a second Lambda. [docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html](https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html) +8. **Socket Mode cannot run on Lambda.** Socket Mode requires a persistent WebSocket connection — Lambda functions are ephemeral. Use EC2, Elastic Beanstalk, or ECS Fargate for Socket Mode bots. HTTP-mode Slack bots work fine on Lambda. +9. **Store secrets in Secrets Manager or SSM Parameter Store.** Never put `SLACK_BOT_TOKEN` or `CLIENT_SECRET` in Lambda environment variables in plaintext for production. Use the SDK to fetch secrets at runtime: `const client = new SecretsManagerClient({}); const secret = await client.send(new GetSecretValueCommand({ SecretId: "bot/slack" }))`. [docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) +10. **For Teams bots on AWS: you still need Azure Bot Service.** Register an App Registration in Entra ID (Azure AD), create a Bot Service resource, and set the messaging endpoint to your AWS URL (e.g., `https://.execute-api..amazonaws.com/api/messages`). Configure `MicrosoftAppId` and `MicrosoftAppPassword` in your AWS environment. [learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration](https://learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration) +11. **Configure Slack app URLs after deployment.** Once your API Gateway or EC2 instance is live, set the Event Subscriptions Request URL and Interactivity URL in the Slack app dashboard to your endpoint (e.g., `https://.execute-api..amazonaws.com/slack/events`). Slack sends a verification challenge immediately — the app must be running. +12. **Set up CloudWatch alarms for error monitoring.** Create alarms for Lambda errors (`Errors` metric > 0), API Gateway 5xx responses, and invocation duration. Use `aws cloudwatch put-metric-alarm` or define them in your SAM/CDK template. [docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html) + +## interview + +### Q1 — Compute Target +``` +question: "Which AWS compute target do you want to deploy to?" +header: "Compute" +options: + - label: "Lambda + API Gateway (Recommended)" + description: "Serverless, pay-per-invocation. Best for HTTP-mode Slack bots. Cannot use Socket Mode." + - label: "EC2 / Elastic Beanstalk" + description: "Always-on VM. Supports Socket Mode, good for Teams bots. ~$8/month for t3.micro." + - label: "ECS / Fargate" + description: "Containerized, production-grade. Auto-scaling, no server management. Good for high-traffic bots." + - label: "You Decide Everything" + description: "Use Lambda + API Gateway (recommended default) and skip remaining questions." +multiSelect: false +``` + +### Q2 — Infrastructure as Code +``` +question: "How do you want to define your infrastructure?" +header: "IaC" +options: + - label: "AWS SAM (Recommended)" + description: "YAML templates for Lambda + API Gateway. sam build && sam deploy — simple and well-documented." + - label: "AWS CDK" + description: "Define infrastructure in TypeScript. Full AWS resource control. More flexible but more setup." + - label: "Manual CLI" + description: "Step-by-step aws CLI commands. Learn exactly what resources are created." + - label: "You Decide Everything" + description: "Use AWS SAM (recommended default) and skip remaining questions." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | Lambda + API Gateway | +| Q2 | AWS SAM | + +## patterns + +### Slack bot on Lambda with SAM + +```yaml +# template.yaml (SAM template) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Slack bot on Lambda + +Globals: + Function: + Timeout: 30 + Runtime: nodejs20.x + MemorySize: 256 + +Resources: + SlackBotFunction: + Type: AWS::Serverless::Function + Properties: + Handler: dist/lambda.handler + CodeUri: . + Events: + SlackEvents: + Type: HttpApi + Properties: + Path: /slack/events + Method: POST + Environment: + Variables: + SLACK_SECRET_NAME: bot/slack # reference, not the actual secret + Policies: + - SecretsManagerReadWrite + +Outputs: + SlackEndpoint: + Description: "URL for Slack Event Subscriptions" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events" +``` + +```bash +# Deploy the SAM stack +sam build +sam deploy --guided \ + --stack-name slack-bot \ + --capabilities CAPABILITY_IAM \ + --resolve-s3 + +# Output shows the API Gateway URL — use it as Slack Request URL +``` + +```typescript +// src/lambda.ts — Lambda handler wrapping Bolt +import { App, AwsLambdaReceiver } from "@slack/bolt"; + +const awsReceiver = new AwsLambdaReceiver({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver: awsReceiver, +}); + +app.message("hello", async ({ say }) => { + await say("Hi from Lambda!"); +}); + +export const handler = async (event: any, context: any, callback: any) => { + const handler = await awsReceiver.start(); + return handler(event, context, callback); +}; +``` + +### Slack bot on EC2 with Elastic Beanstalk + +```bash +# 1. Install EB CLI +pip install awsebcli + +# 2. Initialize the project +eb init slack-bot --platform "Node.js 20" --region us-east-1 + +# 3. Create the environment +eb create slack-bot-env --single --instance-types t3.micro + +# 4. Set environment variables +eb setenv \ + SLACK_BOT_TOKEN=xoxb-your-token \ + SLACK_SIGNING_SECRET=your-signing-secret \ + SLACK_APP_TOKEN=xapp-your-app-token \ + PORT=8080 + +# 5. Deploy +eb deploy + +# 6. Get the URL +eb status # shows CNAME: slack-bot-env.us-east-1.elasticbeanstalk.com + +# Configure Slack Request URL: +# https://slack-bot-env.us-east-1.elasticbeanstalk.com/slack/events +``` + +### Teams bot on AWS (Lambda + Azure Bot Service) + +```bash +# Step 1: Deploy to AWS (same as Slack bot SAM pattern, but different routes) +# In template.yaml, use Path: /api/messages instead of /slack/events + +# Step 2: Register in Azure (required for Teams) +az login +APP_ID=$(az ad app create --display-name "MyBot-AWS" --query appId -o tsv) +APP_SECRET=$(az ad app credential reset --id $APP_ID --query password -o tsv) +TENANT_ID=$(az account show --query tenantId -o tsv) + +az bot create \ + --resource-group rg-mybot \ + --name mybot-aws \ + --app-type SingleTenant \ + --appid $APP_ID \ + --tenant-id $TENANT_ID + +az bot msteams create --resource-group rg-mybot --name mybot-aws + +# Step 3: Set the messaging endpoint to your AWS URL +API_URL="https://abc123.execute-api.us-east-1.amazonaws.com/api/messages" +az bot update --resource-group rg-mybot --name mybot-aws --endpoint $API_URL + +# Step 4: Add Azure credentials to AWS Lambda environment +aws lambda update-function-configuration \ + --function-name MyTeamsBot \ + --environment "Variables={MicrosoftAppId=$APP_ID,MicrosoftAppPassword=$APP_SECRET,MicrosoftAppTenantId=$TENANT_ID}" +``` + +### Socket Mode on EC2 (long-running process) + +```bash +# Socket Mode requires a persistent WebSocket — use EC2 or ECS, not Lambda + +# 1. Launch an EC2 instance (Amazon Linux 2023, t3.micro) +aws ec2 run-instances \ + --image-id ami-0c02fb55956c7d316 \ + --instance-type t3.micro \ + --key-name my-key \ + --security-group-ids sg-xxx \ + --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=slack-bot}]' + +# 2. SSH in and install Node.js +ssh -i my-key.pem ec2-user@ +curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - +sudo yum install -y nodejs + +# 3. Clone, install, build +git clone https://github.com/your-org/your-bot.git +cd your-bot && npm install && npm run build + +# 4. Set environment variables +export SLACK_BOT_TOKEN=xoxb-your-token +export SLACK_APP_TOKEN=xapp-your-app-token +export SLACK_SIGNING_SECRET=your-signing-secret + +# 5. Run with PM2 for process management +npm install -g pm2 +pm2 start dist/index.js --name slack-bot +pm2 save +pm2 startup # auto-restart on reboot +``` + +## pitfalls + +- **Lambda cold starts causing Slack ack timeout.** Node.js Lambda cold starts take 1-5 seconds. If your handler does any work before calling `ack()`, you'll exceed the 3-second Slack deadline. Use provisioned concurrency, or ack immediately and process asynchronously. +- **Socket Mode on Lambda.** Socket Mode requires a persistent WebSocket connection. Lambda functions are ephemeral — they spin down after the request completes. Use EC2, Elastic Beanstalk, or ECS Fargate for Socket Mode. +- **Forgetting Azure Bot Service for Teams bots.** Even though your bot runs on AWS, Teams bots require an Azure Bot Service resource with the messaging endpoint pointing to your AWS URL. Without it, Teams cannot discover or route messages to your bot. +- **API Gateway default timeout.** API Gateway has a 29-second integration timeout. For most bot handlers this is fine, but long-running AI inference calls may exceed it. Use async invocation patterns for heavy processing. +- **Lambda function URL vs API Gateway.** Function URLs are simpler (no API Gateway needed) but lack WAF, throttling, and custom domain support. Use API Gateway for production bots that need rate limiting or custom domains. +- **Missing IAM permissions for Secrets Manager.** If your Lambda execution role doesn't include `secretsmanager:GetSecretValue`, the bot crashes when trying to fetch credentials. Add the policy to the SAM template or IAM role. +- **Elastic Beanstalk port mismatch.** EB expects your app to listen on port 8080 by default (configurable). If your bot hardcodes port 3000, the health check fails and EB marks the instance unhealthy. Always use `process.env.PORT`. + +## references + +- https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html +- https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html +- https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html +- https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html +- https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.html +- https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html +- https://api.slack.com/authentication/basics +- https://slack.dev/bolt-js/deployments/aws-lambda +- https://learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration + +## instructions + +This expert walks through deploying a bot to AWS from scratch — from installing the CLI to verifying a test message. Use it when a developer says "deploy my bot to AWS", "set up Lambda hosting", or "get my bot running on AWS". Covers Slack bots (Lambda or EC2), Teams bots (requires Azure Bot Service + AWS hosting), and Socket Mode considerations. + +Pair with: `../slack/runtime.bolt-foundations-ts.md` (Bolt app setup for receiver selection), `../slack/bolt-oauth-distribution-ts.md` (OAuth for multi-workspace Slack apps), `../security/secrets-ts.md` (secrets best practices), `../bridge/infra-compute-ts.md` (if comparing AWS compute options with Azure equivalents), `azure-bot-deploy-ts.md` (if also needing Azure Bot Service for Teams). + +## research + +Deep Research prompt: + +"Write a micro expert on deploying a Slack Bolt.js or Microsoft Teams bot to AWS. Cover: AWS CLI v2 installation, aws configure / aws sso login, IAM role creation for bot execution, Lambda + API Gateway deployment with SAM (template.yaml, sam build, sam deploy), AwsLambdaReceiver from @slack/bolt, EC2 deployment with PM2 for Socket Mode, Elastic Beanstalk for managed EC2, ECS Fargate for containerized bots, Secrets Manager for credential storage, CloudWatch alarms for error monitoring, provisioned concurrency for cold start mitigation, Teams-on-AWS pattern (Azure Bot Service pointing to AWS endpoint), and Slack app URL configuration. Provide 3-4 canonical deployment examples and 5-7 common pitfalls." diff --git a/skills/microsoft-365-agents-toolkit/experts/deploy/aws-cli-reference-ts.md b/skills/microsoft-365-agents-toolkit/experts/deploy/aws-cli-reference-ts.md new file mode 100644 index 000000000..d3fcaf2a2 --- /dev/null +++ b/skills/microsoft-365-agents-toolkit/experts/deploy/aws-cli-reference-ts.md @@ -0,0 +1,939 @@ +# aws-cli-reference-ts + +## purpose + +Comprehensive reference of all AWS CLI (`aws`) command groups a developer needs for creating, reading, updating, and deleting resources in a bot or AI agent project on AWS. Use as a lookup companion to `aws-bot-deploy-ts.md` (step-by-step deployment) — this file maps every relevant CLI surface so you know what commands exist. + +## rules + +1. **This is a reference, not a tutorial.** For step-by-step deployment walkthroughs, see `aws-bot-deploy-ts.md`. This file catalogs every `aws` command group relevant to bot/agent projects. +2. **Always authenticate first.** Every command below assumes you have run `aws configure` (or `aws sso login`) and verified with `aws sts get-caller-identity`. [docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) +3. **Region matters.** Most commands operate in your configured default region. Override per-command with `--region `, or set globally with `export AWS_DEFAULT_REGION=us-east-1`. + +--- + +## 1. IAM (`aws iam`) — Identity & Access Management + +Every bot needs an execution role with least-privilege permissions. + +### Roles + +| Command | Purpose | +|---|---| +| `aws iam create-role --role-name --assume-role-policy-document file://trust.json` | Create execution role for Lambda/ECS/EC2 bot | +| `aws iam get-role --role-name ` | Read role details including ARN | +| `aws iam list-roles` | List all roles | +| `aws iam update-role --role-name --max-session-duration 7200` | Update role session duration | +| `aws iam update-assume-role-policy --role-name --policy-document file://trust.json` | Update who can assume the role | +| `aws iam delete-role --role-name ` | Delete a role (must detach policies first) | + +### Policies + +| Command | Purpose | +|---|---| +| `aws iam create-policy --policy-name --policy-document file://policy.json` | Create custom policy for bot permissions | +| `aws iam get-policy --policy-arn ` | Read policy metadata | +| `aws iam get-policy-version --policy-arn --version-id v1` | Read actual policy document | +| `aws iam list-policies --scope Local` | List custom policies | +| `aws iam create-policy-version --policy-arn --policy-document file://policy.json --set-as-default` | Update policy (creates new version) | +| `aws iam delete-policy --policy-arn ` | Delete policy | + +### Attach/Detach Policies to Roles + +| Command | Purpose | +|---|---| +| `aws iam attach-role-policy --role-name --policy-arn ` | Attach managed policy to role | +| `aws iam list-attached-role-policies --role-name ` | List policies on a role | +| `aws iam detach-role-policy --role-name --policy-arn ` | Remove policy from role | +| `aws iam put-role-policy --role-name --policy-name --policy-document file://policy.json` | Attach inline policy | +| `aws iam delete-role-policy --role-name --policy-name ` | Delete inline policy | + +### Instance Profiles (for EC2 bots) + +| Command | Purpose | +|---|---| +| `aws iam create-instance-profile --instance-profile-name ` | Create instance profile for EC2 | +| `aws iam add-role-to-instance-profile --instance-profile-name --role-name ` | Link role to instance profile | +| `aws iam remove-role-from-instance-profile --instance-profile-name --role-name ` | Unlink role | +| `aws iam delete-instance-profile --instance-profile-name ` | Delete instance profile | + +Reference: [docs.aws.amazon.com/cli/latest/reference/iam](https://docs.aws.amazon.com/cli/latest/reference/iam) + +--- + +## 2. Lambda (`aws lambda`) — Serverless Bot Hosting + +### Functions + +| Command | Purpose | +|---|---| +| `aws lambda create-function --function-name --runtime nodejs20.x --role --handler index.handler --zip-file fileb://function.zip` | Create bot function | +| `aws lambda get-function --function-name ` | Read function config and code location | +| `aws lambda get-function-configuration --function-name ` | Read runtime config only | +| `aws lambda list-functions` | List all functions | +| `aws lambda update-function-code --function-name --zip-file fileb://function.zip` | Deploy new bot code | +| `aws lambda update-function-code --function-name --image-uri ` | Deploy from container image | +| `aws lambda update-function-configuration --function-name --timeout 30 --memory-size 256 --environment "Variables={KEY=value}"` | Update runtime settings | +| `aws lambda delete-function --function-name ` | Delete function | + +### Invocation & Testing + +| Command | Purpose | +|---|---| +| `aws lambda invoke --function-name --payload file://event.json output.json` | Invoke synchronously (test) | +| `aws lambda invoke --function-name --invocation-type Event --payload file://event.json output.json` | Invoke async (fire-and-forget) | + +### Event Source Mappings (SQS trigger for async bot processing) + +| Command | Purpose | +|---|---| +| `aws lambda create-event-source-mapping --function-name --event-source-arn --batch-size 10` | Connect SQS queue to Lambda | +| `aws lambda list-event-source-mappings --function-name ` | List triggers | +| `aws lambda update-event-source-mapping --uuid --batch-size 5` | Update trigger | +| `aws lambda delete-event-source-mapping --uuid ` | Remove trigger | + +### Permissions (resource-based policy) + +| Command | Purpose | +|---|---| +| `aws lambda add-permission --function-name --statement-id apigateway --action lambda:InvokeFunction --principal apigateway.amazonaws.com --source-arn ` | Allow API Gateway to invoke | +| `aws lambda get-policy --function-name ` | Read resource policy | +| `aws lambda remove-permission --function-name --statement-id apigateway` | Revoke permission | + +### Aliases & Versions (deployment strategy) + +| Command | Purpose | +|---|---| +| `aws lambda publish-version --function-name ` | Publish immutable version | +| `aws lambda create-alias --function-name --name prod --function-version 3` | Create alias pointing to version | +| `aws lambda update-alias --function-name --name prod --function-version 4` | Shift alias to new version | +| `aws lambda delete-alias --function-name --name prod` | Delete alias | + +### Function URL (alternative to API Gateway) + +| Command | Purpose | +|---|---| +| `aws lambda create-function-url-config --function-name --auth-type NONE` | Create public HTTPS endpoint | +| `aws lambda get-function-url-config --function-name ` | Read URL config | +| `aws lambda update-function-url-config --function-name --auth-type AWS_IAM` | Update auth type | +| `aws lambda delete-function-url-config --function-name ` | Delete URL endpoint | + +### Layers + +| Command | Purpose | +|---|---| +| `aws lambda publish-layer-version --layer-name --zip-file fileb://layer.zip --compatible-runtimes nodejs20.x` | Publish shared dependency layer | +| `aws lambda list-layers` | List available layers | +| `aws lambda delete-layer-version --layer-name --version-number 1` | Delete layer version | + +Reference: [docs.aws.amazon.com/cli/latest/reference/lambda](https://docs.aws.amazon.com/cli/latest/reference/lambda) + +--- + +## 3. API Gateway — HTTP Endpoints for Bots + +### HTTP API (`aws apigatewayv2`) — Recommended for bot webhooks + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-api --name --protocol-type HTTP` | Create HTTP API | +| `aws apigatewayv2 get-api --api-id ` | Read API details | +| `aws apigatewayv2 get-apis` | List APIs | +| `aws apigatewayv2 update-api --api-id --name ` | Update API | +| `aws apigatewayv2 delete-api --api-id ` | Delete API | + +### Integrations + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-integration --api-id --integration-type AWS_PROXY --integration-uri --payload-format-version 2.0` | Connect Lambda backend | +| `aws apigatewayv2 get-integration --api-id --integration-id ` | Read integration | +| `aws apigatewayv2 update-integration --api-id --integration-id --timeout-in-millis 10000` | Update integration | +| `aws apigatewayv2 delete-integration --api-id --integration-id ` | Remove integration | + +### Routes + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-route --api-id --route-key "POST /slack/events" --target integrations/` | Create route for Slack events | +| `aws apigatewayv2 get-routes --api-id ` | List routes | +| `aws apigatewayv2 update-route --api-id --route-id --route-key "POST /slack/interactions"` | Update route | +| `aws apigatewayv2 delete-route --api-id --route-id ` | Delete route | + +### Stages & Deployment + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-stage --api-id --stage-name prod --auto-deploy` | Create stage with auto-deploy | +| `aws apigatewayv2 get-stages --api-id ` | List stages | +| `aws apigatewayv2 update-stage --api-id --stage-name prod --stage-variables env=production` | Update stage variables | +| `aws apigatewayv2 delete-stage --api-id --stage-name prod` | Delete stage | + +### Custom Domain + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-domain-name --domain-name bot.example.com --domain-name-configurations CertificateArn=` | Map custom domain | +| `aws apigatewayv2 create-api-mapping --api-id --domain-name bot.example.com --stage prod` | Map domain to stage | +| `aws apigatewayv2 delete-domain-name --domain-name bot.example.com` | Remove custom domain | + +### REST API (`aws apigateway`) — When you need request validation, API keys, usage plans + +| Command | Purpose | +|---|---| +| `aws apigateway create-rest-api --name --endpoint-configuration types=REGIONAL` | Create REST API | +| `aws apigateway get-rest-api --rest-api-id ` | Read API | +| `aws apigateway get-rest-apis` | List REST APIs | +| `aws apigateway delete-rest-api --rest-api-id ` | Delete API | +| `aws apigateway get-resources --rest-api-id ` | List resources/paths | +| `aws apigateway create-resource --rest-api-id --parent-id --path-part slack` | Create path segment | +| `aws apigateway put-method --rest-api-id --resource-id --http-method POST --authorization-type NONE` | Create method | +| `aws apigateway put-integration --rest-api-id --resource-id --http-method POST --type AWS_PROXY --integration-http-method POST --uri ` | Connect to Lambda | +| `aws apigateway create-deployment --rest-api-id --stage-name prod` | Deploy changes | + +Reference: [docs.aws.amazon.com/cli/latest/reference/apigatewayv2](https://docs.aws.amazon.com/cli/latest/reference/apigatewayv2) + +--- + +## 4. EC2 (`aws ec2`) — VM Hosting for Socket Mode Bots + +### Instances + +| Command | Purpose | +|---|---| +| `aws ec2 run-instances --image-id --instance-type t3.micro --key-name --security-group-ids --subnet-id --iam-instance-profile Name= --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=slack-bot}]"` | Launch bot instance | +| `aws ec2 describe-instances --filters "Name=tag:Name,Values=slack-bot"` | Find bot instances | +| `aws ec2 describe-instance-status --instance-ids ` | Check instance health | +| `aws ec2 start-instances --instance-ids ` | Start stopped instance | +| `aws ec2 stop-instances --instance-ids ` | Stop instance (preserve state) | +| `aws ec2 reboot-instances --instance-ids ` | Reboot instance | +| `aws ec2 terminate-instances --instance-ids ` | Delete instance permanently | + +### Key Pairs (SSH access) + +| Command | Purpose | +|---|---| +| `aws ec2 create-key-pair --key-name --query "KeyMaterial" --output text > key.pem` | Create SSH key pair | +| `aws ec2 describe-key-pairs` | List key pairs | +| `aws ec2 delete-key-pair --key-name ` | Delete key pair | + +### Security Groups (firewall) + +| Command | Purpose | +|---|---| +| `aws ec2 create-security-group --group-name bot-sg --description "Bot security group" --vpc-id ` | Create security group | +| `aws ec2 authorize-security-group-ingress --group-id --protocol tcp --port 443 --cidr 0.0.0.0/0` | Allow inbound HTTPS | +| `aws ec2 describe-security-groups --group-ids ` | Read rules | +| `aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 22 --cidr 0.0.0.0/0` | Remove inbound rule | +| `aws ec2 delete-security-group --group-id ` | Delete security group | + +### VPC Basics + +| Command | Purpose | +|---|---| +| `aws ec2 describe-vpcs` | List VPCs | +| `aws ec2 describe-subnets --filters "Name=vpc-id,Values="` | List subnets in VPC | + +### AMI (machine images) + +| Command | Purpose | +|---|---| +| `aws ec2 describe-images --owners amazon --filters "Name=name,Values=al2023-ami-*-x86_64"` | Find Amazon Linux AMI | +| `aws ec2 create-image --instance-id --name "bot-snapshot"` | Create AMI from running instance | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ec2](https://docs.aws.amazon.com/cli/latest/reference/ec2) + +--- + +## 5. ECS (`aws ecs`) — Containerized Bot Hosting + +### Clusters + +| Command | Purpose | +|---|---| +| `aws ecs create-cluster --cluster-name --capacity-providers FARGATE --default-capacity-provider-strategy capacityProvider=FARGATE,weight=1` | Create Fargate cluster | +| `aws ecs describe-clusters --clusters ` | Read cluster details | +| `aws ecs list-clusters` | List clusters | +| `aws ecs delete-cluster --cluster ` | Delete cluster (must be empty) | + +### Task Definitions (container blueprint) + +| Command | Purpose | +|---|---| +| `aws ecs register-task-definition --cli-input-json file://task-def.json` | Create/update task definition | +| `aws ecs describe-task-definition --task-definition ` | Read latest task def | +| `aws ecs describe-task-definition --task-definition :` | Read specific revision | +| `aws ecs list-task-definitions --family-prefix ` | List revisions | +| `aws ecs deregister-task-definition --task-definition :` | Deactivate revision | + +### Services (long-running bot) + +| Command | Purpose | +|---|---| +| `aws ecs create-service --cluster --service-name --task-definition --desired-count 1 --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[],securityGroups=[],assignPublicIp=ENABLED}"` | Create service | +| `aws ecs describe-services --cluster --services ` | Read service status | +| `aws ecs list-services --cluster ` | List services | +| `aws ecs update-service --cluster --service --desired-count 2` | Scale service | +| `aws ecs update-service --cluster --service --task-definition : --force-new-deployment` | Deploy new version | +| `aws ecs delete-service --cluster --service --force` | Delete service | + +### Tasks (individual containers) + +| Command | Purpose | +|---|---| +| `aws ecs run-task --cluster --task-definition --launch-type FARGATE --network-configuration "awsvpcConfiguration={...}"` | Run one-off task | +| `aws ecs list-tasks --cluster --service-name ` | List running tasks | +| `aws ecs describe-tasks --cluster --tasks ` | Read task details | +| `aws ecs stop-task --cluster --task --reason "manual stop"` | Stop a running task | +| `aws ecs execute-command --cluster --task --container --interactive --command "/bin/sh"` | Exec into running container | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ecs](https://docs.aws.amazon.com/cli/latest/reference/ecs) + +--- + +## 6. Elastic Beanstalk (`aws elasticbeanstalk`) — Managed Hosting + +| Command | Purpose | +|---|---| +| `aws elasticbeanstalk create-application --application-name ` | Create application | +| `aws elasticbeanstalk describe-applications --application-names ` | Read application | +| `aws elasticbeanstalk update-application --application-name --description "Slack bot"` | Update application | +| `aws elasticbeanstalk delete-application --application-name --terminate-env-by-force` | Delete application | +| `aws elasticbeanstalk create-application-version --application-name --version-label v1 --source-bundle S3Bucket=,S3Key=` | Upload version | +| `aws elasticbeanstalk create-environment --application-name --environment-name prod --solution-stack-name "64bit Amazon Linux 2023 v6.1.0 running Node.js 20" --option-settings file://options.json` | Create environment | +| `aws elasticbeanstalk describe-environments --application-name ` | Read environment status | +| `aws elasticbeanstalk update-environment --environment-name --version-label v2` | Deploy new version | +| `aws elasticbeanstalk terminate-environment --environment-name ` | Delete environment | +| `aws elasticbeanstalk list-platform-versions --filters "Type=PlatformName,Operator=contains,Values=Node.js"` | Find supported platforms | + +Reference: [docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk](https://docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk) + +--- + +## 7. App Runner (`aws apprunner`) — Simplified Container Hosting + +| Command | Purpose | +|---|---| +| `aws apprunner create-service --service-name --source-configuration file://source-config.json` | Create service from ECR image or GitHub | +| `aws apprunner describe-service --service-arn ` | Read service details and URL | +| `aws apprunner list-services` | List services | +| `aws apprunner update-service --service-arn --source-configuration file://source-config.json` | Update source/config | +| `aws apprunner delete-service --service-arn ` | Delete service | +| `aws apprunner start-deployment --service-arn ` | Trigger manual deployment | +| `aws apprunner pause-service --service-arn ` | Pause (stop billing for compute) | +| `aws apprunner resume-service --service-arn ` | Resume paused service | +| `aws apprunner associate-custom-domain --service-arn --domain-name bot.example.com` | Map custom domain | +| `aws apprunner disassociate-custom-domain --service-arn --domain-name bot.example.com` | Remove custom domain | + +Reference: [docs.aws.amazon.com/cli/latest/reference/apprunner](https://docs.aws.amazon.com/cli/latest/reference/apprunner) + +--- + +## 8. Secrets Manager (`aws secretsmanager`) — Bot Credentials + +| Command | Purpose | +|---|---| +| `aws secretsmanager create-secret --name bot/slack --secret-string '{"SLACK_BOT_TOKEN":"xoxb-...","SLACK_SIGNING_SECRET":"..."}'` | Store bot credentials | +| `aws secretsmanager get-secret-value --secret-id bot/slack` | Read secret value | +| `aws secretsmanager describe-secret --secret-id bot/slack` | Read metadata (no value) | +| `aws secretsmanager list-secrets --filters Key=name,Values=bot/` | List secrets | +| `aws secretsmanager update-secret --secret-id bot/slack --secret-string '{"SLACK_BOT_TOKEN":"xoxb-new"}'` | Update secret value | +| `aws secretsmanager rotate-secret --secret-id bot/slack --rotation-lambda-arn ` | Trigger rotation | +| `aws secretsmanager delete-secret --secret-id bot/slack --recovery-window-in-days 7` | Soft delete (recoverable) | +| `aws secretsmanager delete-secret --secret-id bot/slack --force-delete-without-recovery` | Hard delete (immediate) | +| `aws secretsmanager restore-secret --secret-id bot/slack` | Recover soft-deleted secret | + +Reference: [docs.aws.amazon.com/cli/latest/reference/secretsmanager](https://docs.aws.amazon.com/cli/latest/reference/secretsmanager) + +--- + +## 9. SSM Parameter Store (`aws ssm`) — Configuration & Secrets + +| Command | Purpose | +|---|---| +| `aws ssm put-parameter --name /bot/config/log-level --value "info" --type String` | Create string parameter | +| `aws ssm put-parameter --name /bot/secrets/api-key --value "sk-..." --type SecureString` | Create encrypted parameter | +| `aws ssm get-parameter --name /bot/config/log-level` | Read parameter | +| `aws ssm get-parameter --name /bot/secrets/api-key --with-decryption` | Read encrypted parameter | +| `aws ssm get-parameters-by-path --path /bot/ --recursive --with-decryption` | Read all params under path | +| `aws ssm describe-parameters --parameter-filters "Key=Name,Option=BeginsWith,Values=/bot/"` | List parameters (metadata only) | +| `aws ssm put-parameter --name /bot/config/log-level --value "debug" --type String --overwrite` | Update parameter | +| `aws ssm delete-parameter --name /bot/config/log-level` | Delete parameter | +| `aws ssm delete-parameters --names /bot/config/log-level /bot/config/timeout` | Batch delete | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ssm](https://docs.aws.amazon.com/cli/latest/reference/ssm) + +--- + +## 10. CloudWatch & Logs (`aws cloudwatch`, `aws logs`) — Monitoring + +### CloudWatch Metrics & Alarms + +| Command | Purpose | +|---|---| +| `aws cloudwatch put-metric-alarm --alarm-name bot-errors --metric-name Errors --namespace AWS/Lambda --statistic Sum --period 300 --threshold 5 --comparison-operator GreaterThanThreshold --evaluation-periods 1 --alarm-actions --dimensions Name=FunctionName,Value=` | Create error alarm | +| `aws cloudwatch describe-alarms --alarm-names bot-errors` | Read alarm config | +| `aws cloudwatch list-metrics --namespace AWS/Lambda --dimensions Name=FunctionName,Value=` | List available metrics | +| `aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name Duration --dimensions Name=FunctionName,Value= --start-time