# Microsoft Teams — exploration for v0.2 **Short version:** Teams is meaningfully harder than Discord, Slack and Telegram, and the reason is structural rather than incidental. Every path that lets an outsider *post* into Teams without a bot has been retired or cannot edit. Live tiles in Teams therefore require a **Bot Framework bot**, which means an Azure Bot registration, an app manifest, and — for a self-hosted homelab — an inbound HTTPS endpoint or a workaround for not having one. This document lays out the options and recommends one. --- ## 1. What no longer works ### Office 365 Connectors / "Incoming Webhook" The classic Teams incoming webhook was an Office 365 Connector. Microsoft retired the connector platform: URLs had to be migrated by January 2025, the service was disabled for Teams in **May 2026**, and it is now gone. Even when it worked, a connector could only *post* a message; there was no edit call. So it was never a live-tile candidate, and today it isn't a candidate for anything. ### Power Automate "Workflows" webhooks Microsoft's designated replacement. A Workflows-app webhook accepts a POST and runs a flow that posts to a channel (Adaptive Cards included, private channels supported since April 2026). It is fine for notifications. It cannot edit a message it posted earlier — the flow's "Post card in a chat or channel" action creates; there is a separate "Update an adaptive card" action, but it requires the flow to have posted the card *and* to still hold its state, and it is driven by the flow's own triggers, not by an external caller saying "edit message X". Chaining that into a 30-second edit loop from outside is not a supported shape and would be fragile even where it half-works. **Verdict:** Workflows can be a v0.2 *notification* target (post-only, one message per roll — noisy), not a live-tile target. ### Microsoft Graph `PATCH /teams/{team}/channels/{channel}/messages/{id}` exists. But `chatMessage` update is **delegated-permission only** — an application (daemon) token cannot call it. A self-hosted service has no signed-in user to delegate from without a full interactive OAuth flow and refresh-token management, and Graph would also require the message to have been *posted* by that same user. Not a fit for an unattended service. --- ## 2. What does work: a Bot Framework bot A bot registered in Azure Bot Service can post an activity to a channel and later call `updateActivity` on it — the Teams equivalent of `chat.update`. This is exactly the primitive OpenMFA needs, and it is the only supported one. What it involves: 1. **Azure Bot registration** (free tier). Yields a Microsoft App ID and a client secret (or certificate). No Azure spend is required for the bot resource itself. 2. **Teams app manifest** declaring the bot, sideloaded into the tenant or installed to a team. The tenant admin must permit custom app upload. 3. **A messaging endpoint.** Teams sends the bot an HTTPS POST when the bot is installed, added to a team, or messaged. The bot needs this at least *once* to learn the `conversation.id` and `serviceUrl` for the target channel — after that, OpenMFA can post and edit proactively using only stored values plus a token from the Bot Framework OAuth endpoint. 4. **Proactive messaging.** With a stored conversation reference, the bot authenticates to `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token` with its App ID and secret, then calls: - `POST {serviceUrl}/v3/conversations/{conversationId}/activities` → create, returns `{ id }` - `PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}` → **update** - `DELETE …/activities/{activityId}` → delete That maps one-to-one onto the `Sink` trait already in `crates/delivery`: `create` / `edit` / `delete`. The engine would not change. 5. **Message format.** An **Adaptive Card** with the code in a monospace `TextBlock` (`fontType: Monospace`) and a `Copy` action is the natural tile. Adaptive Cards don't have a live-ticking timestamp like Discord's ``, but they do support `{{DATE()}}` / `{{TIME()}}` formatting functions that render in the viewer's locale — the Slack-equivalent "renders once, in local time" behaviour. ### Rate limits Bot Framework in Teams allows roughly **1 message per second per bot per conversation**, with a burst allowance, and Teams throttles harder on channel messages than on 1:1 chats. Editing counts. With one edit per account per 30 seconds, a channel would comfortably carry **~15–20 accounts**; the same one-second governor used for Slack and Telegram fits. ### The self-hosting problem, and three answers The messaging endpoint is the sticking point for a homelab. Teams must be able to reach the bot over public HTTPS, at least during installation. Options, in order of preference: **A. Cloudflare Tunnel (recommended).** Miles already fronts Gitea with Cloudflare. A named tunnel exposing `/api/teams/messages` on the OpenMFA container gives Teams a stable public HTTPS URL with no port forwarding and no certificate management. The endpoint only needs to validate the Bot Framework JWT on inbound requests and store the conversation reference — a few dozen lines with `jsonwebtoken` plus the Microsoft OpenID metadata for key discovery. This keeps OpenMFA fully self-hosted. **B. One-time capture, then close the door.** Because the conversation reference is only *learned* via the inbound endpoint, the tunnel could be opened once during setup, the reference stored in `settings.json`, and the tunnel torn down. Proactive posting and editing need only outbound HTTPS. Downside: if the bot is removed and re-added, or the team is recreated, the reference goes stale and the door has to be reopened. Workable for a single-user homelab; clunky as a product default. **C. Manual reference entry.** The `serviceUrl` for a tenant is stable (`https://smba.trafficmanager.net//`) and the channel conversation id is visible in a Teams channel link (`19:…@thread.tacv2`). In principle an operator could paste both. In practice the id format has changed across Teams generations and proactive posting to a conversation the bot has never been installed into is rejected, so this only works after installation anyway. Not recommended as the primary path, but useful as an override field. --- ## 3. Recommended v0.2 scope 1. **`crates/delivery/src/teams.rs`** — `TeamsClient` with token acquisition (client-credentials against `botframework.com`), `create` / `update` / `delete` activities, Adaptive Card builder, and error classification (`ConversationNotFound` → repost; 401/403 on token → fatal; 429 → back off using `Retry-After`). 2. **`Provider::Teams`** in `core::settings` with `TeamsConfig { app_id, app_secret, tenant_id, conversation_id, service_url }`. Validation on the GUID shapes; `destination_key` on `conversation_id`. 3. **Inbound endpoint** `POST /api/teams/messages`, unauthenticated by session but **gated by Bot Framework JWT validation** (issuer, audience = App ID, signature via Microsoft's JWKS). Handles `conversationUpdate` (bot added → store reference) and ignores everything else. This is the only public surface OpenMFA would ever expose and it must accept nothing but a valid Microsoft-signed token. 4. **Settings UI** — a fourth segment in the picker with a guided flow: paste App ID + secret → OpenMFA shows the public endpoint URL to put in the Azure Bot's *Messaging endpoint* → generate a downloadable Teams app manifest (`manifest.json` + icons, zipped) → "install it to your team, then click Detect" → the stored conversation appears, exactly like Telegram's chat detection. 5. **Tunnel documentation** — a `cloudflared` sidecar example in `docker-compose.yml` and an Unraid note. Effort is roughly two to three times the Slack integration: the API surface is similar, but JWT validation, manifest generation and the inbound endpoint are new machinery. Nothing in it changes the engine, the vault, or the other three platforms. --- ## 4. What I would *not* do - Don't ship Teams via Workflows webhooks as if it were live. Posting a new message every 30 seconds fills a channel with 2,880 messages a day per account. If a post-only Teams mode is wanted for notifications, it should be a separate, clearly labelled "announce" mode — not the default and not called live. - Don't attempt Graph delegated auth. It would make OpenMFA hold a *user's* refresh token for their Microsoft account to edit chat messages, which is a larger credential than a bot secret and a worse thing to leak. --- ## Sources - Microsoft 365 Developer Blog — *Retirement of Office 365 connectors within Microsoft Teams* - Microsoft Learn — *Create an Incoming Webhook* (Teams), with the Workflows migration notice - Microsoft Learn — Bot Framework REST API: *Update Activity*, *Send to Conversation*, *Proactive messages in Teams* - Microsoft Learn — Teams bot rate limiting - Microsoft Graph — `chatMessage: update` (delegated permissions only)