msteams provider
What it is
Imitates a Microsoft Teams incoming webhook: the retired Office 365 / M365
connector's MessageCard format and the current Power-Automate-backed
workflow trigger's
Adaptive Card format, both on the real webhook URL shape:
POST /webhookb2/{guid}@{tenant}/IncomingWebhook/{id}/{key}
Bot Framework (POST /v3/conversations/{id}/activities) needs an OAuth token
exchange and is deliberately out of scope; see
docs/implementation-plan.md §12.
What it's for
The same webhook URL your deploy pipeline, monitoring tool or bot already posts to when it notifies a Teams channel — point it at tommy while developing or testing that integration, and see the card rendered (or the raw JSON, when no renderer is wired up) instead of a real channel filling up with test noise. Particularly useful while building an Adaptive Card layout: Teams gives no local preview for the workflow-trigger shape, so posting here and reading the capture back is the fast iteration loop.
How to test it for real
TOMMY_NO_UPDATE_CHECK=1 go run . chat --ui-port 18931 --in-port 18932 --enabled-providers msteams
A MessageCard, the retired Office 365 connector format:
curl -si http://localhost:18932/webhookb2/11111111-1111-1111-1111-111111111111@22222222-2222-2222-2222-222222222222/IncomingWebhook/33333333333333333333333333333333/44444444-4444-4444-4444-444444444444 \
-H 'Content-Type: application/json' -d '{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "Build failed",
"themeColor": "FF0000",
"title": "Build #482 failed",
"text": "It works.",
"sections": [{
"activityTitle": "deploy-bot",
"activitySubtitle": "2 minutes ago",
"facts": [{"name": "Branch", "value": "main"}]
}],
"potentialAction": [{
"@type": "OpenUri",
"name": "View build",
"targets": [{"os": "default", "uri": "https://example.com/build/482"}]
}]
}'
returned HTTP/1.1 200 OK, Content-Type: text/plain, body 1 — Teams'
actual connector success response. An Adaptive Card through a workflow
trigger:
curl -si http://localhost:18932/webhookb2/11111111-1111-1111-1111-111111111111@22222222-2222-2222-2222-222222222222/IncomingWebhook/33333333333333333333333333333333/44444444-4444-4444-4444-444444444444 \
-H 'Content-Type: application/json' -d '{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [{"type": "TextBlock", "text": "It works.", "weight": "bolder"}]
}
}]
}'
returned HTTP/1.1 202 Accepted with an empty body, exactly as the two
"Deciding the response" rules below require. Read either back:
curl -s http://localhost:18931/api/v1/chat/messages | jq '.[0].message.text'
curl -s 'http://localhost:18931/api/v1/events?plugin=chat' | jq '.[0].meta'
or open the tab at http://localhost:18931/ui/chat/.
Route
{guid}@{tenant} is one URL path segment - the two identifiers are joined by
an @, not a / - so it is mounted as a single wildcard, {GuidTenant},
which the handler splits on @ itself. This is the same rule the twilio
provider hit from the other side (a wildcard cannot share a segment with a
literal suffix like .json): net/http's ServeMux requires a wildcard to
occupy a whole path segment, and a single wildcard over guid@tenant is
exactly what that segment needs.
Two payload generations
- MessageCard (the retired O365/M365 connector format): a JSON object
whose top level carries
"@type": "MessageCard", along with@context,summary,themeColor,title,text, asections[]array (activityTitle,activitySubtitle,activityImage,facts,text) andpotentialAction[]. - Adaptive Card, current generation:
{"type":"message","attachments": [{"contentType":"application/vnd.microsoft.card.adaptive","content":{…}}]}. A workflow trigger also accepts a bare{"text":"..."}body directly, per Microsoft's own "Send a request to the webhook" example.
Deciding the response
This is the easy thing to get wrong, so here is the rule: a body carrying
top-level "@type":"MessageCard" is a connector webhook, and gets that
generation's real success response — the literal digit 1, as text/plain,
200 OK. Everything else — an Adaptive Card envelope, or the bare
{"text":"..."} shape — is a workflow trigger, and gets 202 Accepted with
an empty body.
This is a payload-shape rule rather than a path or header rule, because
both generations share the identical webhookb2 URL shape in the wild (the
URL is the only "credential" either one presents) — there is nothing else to
key off. A workflow trigger can itself accept a MessageCard (Teams
"Workflows support both Adaptive Cards and Message Card format"), but the
reverse is never true, so keying off @type is the one signal that never
misclassifies a genuine connector card.
Converting into the canonical message
Channel.IDis the request's own path (/webhookb2/{guid}@{tenant}/ IncomingWebhook/{id}/{key}) — stable for as long as the webhook URL is, which is exactly the scope of "the webhook target" the canonical model asks for. There is no channel name in either payload, soChannel.Namestays empty.- A
MessageCard'ssections[0].activityTitle/activityImagebecomeAuthor.Name/Author.IconURL— the closest thing a MessageCard has to "who posted this".Author.Botis alwaystrue. Textprefers the payload's own top-leveltext, thensummary; when neither is present (a bare card with only section text, or an Adaptive Card with nothing at the top level),chat.Message.Normalizederives it from the structured content viachat.FallbackText, which already knows to harvestactivityTitle/activitySubtitle/text— exactly the fields these two schemas use for prose.- The inner card object is stored as
chat.FormatTeamsAdaptiveCard, never the{"type":"message","attachments":[…]}envelope around it — ajson.RawMessagefield decodes to the exact input bytes for that value, so this is byte-for-byte with no re-marshaling. AMessageCardpayload has no envelope to begin with, so the whole (trimmed) request body is stored aschat.FormatTeamsMessageCard. Several attachments produce severalContententries, in the order they arrived. ThreadTSis always empty: Teams webhooks have no thread semantics.themeColor,potentialAction,@contextand the four webhook path components (guid,tenant,id,key, plus the fullpathand agenerationtag ofconnectororworkflow) go onEvent.Meta, never on the canonicalMessage.
Auth
None is presented beyond the URL itself, which is the secret — nothing is
ever rejected for it. The path components are recorded on Event.Meta for
inspection, never validated.
Errors
- Invalid JSON, or a body over Teams' own documented 28KB message limit
(
msteams.MaxBody), or valid JSON with no card and no top-leveltext/summary— that last one is the same "Bad payload received by generic incoming webhook" condition the live endpoint reports, so the fake answers with its own real message,Summary or Text is required., astext/plain(there is no documented JSON error envelope here, unlike SendGrid or Twilio).
Package tests
Run the package tests, which cover both payload generations, both response
shapes, the Adaptive Card being stored unwrapped (byte for byte, no
re-marshaling), multiple attachments in order, fallback text from a card with
no top-level text, the webhook's path components landing in Event.Meta, and
malformed/oversized/missing-content payloads:
go test ./plugins/chat/providers/msteams/...
go test -race ./plugins/chat/providers/msteams/...