Platform-neutral successor to Discord Authenticator, forked from its v0.1.2
state with fresh history. Same vault, same UI, same live-message mechanism —
now behind one `Sink` that speaks three platforms.
Why each platform needs what it needs (this drove the whole settings model):
- Discord: an incoming-webhook URL. Webhooks can edit their own messages.
- Slack: a bot token (xoxb-, scope chat:write) + channel ID. Slack incoming
webhooks can post but never edit, and editing IS the live mechanism, so
chat.postMessage / chat.update via a bot is the only path.
- Telegram: a bot token + chat id. No webhook concept exists. The app detects
the chat id itself from the bot's pending updates ("send /start, then
Detect"), since users rarely know it.
- core: Provider enum; settings hold all three configs so switching back and
forth never re-asks for tokens; per-platform shape validation that names the
common mistakes (xoxp- user token, #name instead of channel ID); a
destination key that clears message ids on platform/channel change but not
on token rotation
- delivery: shared DeliveryError + pacing Governor; Discord client refactored
onto them; new Slack client (classifies Slack's ok:false-on-HTTP-200
errors) and Telegram client ("message is not modified" is success; chat
detection via getUpdates); provider-agnostic message builders — code in a
copyable span everywhere, rollover time as live as each platform allows
- http-api: setup accepts an optional provider; settings PUT/GET carry the
full model; /api/slack/whoami, /api/telegram/whoami, /api/telegram/chats
- webui: three-way optional picker on setup and in Settings → Delivery with
guided per-platform fields; "Check token" (Slack) and "Detect chat"
(Telegram); inline white mark via currentColor
- brand: white mark on the dark plate; blue stays with the Discord sibling
- docs/TEAMS.md: exploration for v0.2 — Teams connectors were disabled May
2026 and Workflows webhooks can't edit, so live tiles need a Bot Framework
bot; options for the inbound endpoint on a homelab and a recommended scope
Verified against mock Discord, Slack and Telegram servers speaking each
platform's real protocol shape: edits target the same message rather than
re-posting, deleted tiles are recovered, dead credentials are surfaced with a
next step, renames refresh without a rollover, and switching platform reposts
on the new one and leaves the old one alone. 138 tests.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
177 lines
9.0 KiB
Markdown
177 lines
9.0 KiB
Markdown
# 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 `<t:R>`,
|
||
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/<region>/`) 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)
|