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]>
9.0 KiB
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:
-
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.
-
Teams app manifest declaring the bot, sideloaded into the tenant or installed to a team. The tenant admin must permit custom app upload.
-
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.idandserviceUrlfor the target channel — after that, OpenMFA can post and edit proactively using only stored values plus a token from the Bot Framework OAuth endpoint. -
Proactive messaging. With a stored conversation reference, the bot authenticates to
https://login.microsoftonline.com/botframework.com/oauth2/v2.0/tokenwith its App ID and secret, then calls:POST {serviceUrl}/v3/conversations/{conversationId}/activities→ create, returns{ id }PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}→ updateDELETE …/activities/{activityId}→ delete
That maps one-to-one onto the
Sinktrait already incrates/delivery:create/edit/delete. The engine would not change. -
Message format. An Adaptive Card with the code in a monospace
TextBlock(fontType: Monospace) and aCopyaction 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
crates/delivery/src/teams.rs—TeamsClientwith token acquisition (client-credentials againstbotframework.com),create/update/deleteactivities, Adaptive Card builder, and error classification (ConversationNotFound→ repost; 401/403 on token → fatal; 429 → back off usingRetry-After).Provider::Teamsincore::settingswithTeamsConfig { app_id, app_secret, tenant_id, conversation_id, service_url }. Validation on the GUID shapes;destination_keyonconversation_id.- 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). HandlesconversationUpdate(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. - 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. - Tunnel documentation — a
cloudflaredsidecar example indocker-compose.ymland 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)