From 36cb95ac48625b614ae278d2eedb96a8e709ca65 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Fri, 25 Sep 2026 12:49:14 -0400 Subject: [PATCH 1/2] Send admin Teams notifications in the Workflows message format The admin notification webhook is a Teams "Send webhook alerts to a channel" workflow, which posts one card per entry in "attachments". A bare Adaptive Card has no attachments, so runs succeeded without posting. Admin notifications now wrap the card in a message envelope; TEAMS_WEBHOOK_FORMAT=card keeps the bare card for classic flows. The older recipe-proposal and retrain alert senders are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/ADMIN_NOTIFICATIONS.md | 26 +++++++++++++------- api/platform_notifications.py | 31 +++++++++++++++++++++--- api/tests/test_admin_notifications_pg.py | 8 ++++-- api/tests/test_platform_notifications.py | 16 ++++++++++++ 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/api/ADMIN_NOTIFICATIONS.md b/api/ADMIN_NOTIFICATIONS.md index b0db83a..50eba38 100644 --- a/api/ADMIN_NOTIFICATIONS.md +++ b/api/ADMIN_NOTIFICATIONS.md @@ -46,15 +46,23 @@ configured, email is skipped for every notification. `GET /api/health` reports ### Teams -`ADMIN_TEAMS_WEBHOOK_URL`: the HTTP trigger URL of a Power Automate flow (or -Logic App), the same kind used for the retrain alerts: - -1. Power Automate → Create → Instant cloud flow → trigger **When a Teams - webhook request is received** (or **When a HTTP request is received**). -2. Add **Post card in a chat or channel**; post as Flow bot to the channel or - chat you want, and set the card to the trigger body (`triggerBody()`). The - API sends an Adaptive Card. -3. Save and copy the trigger URL into `ADMIN_TEAMS_WEBHOOK_URL`. +`ADMIN_TEAMS_WEBHOOK_URL`: the URL of a Teams **Workflows** webhook. The +simplest is the built-in template: + +1. In Teams, open the channel → **Workflows** → **Send webhook alerts to a + channel** (or in Power Automate, the same template). +2. Save it and copy the webhook URL into `ADMIN_TEAMS_WEBHOOK_URL`. + +The API sends each card as a Teams message +(`{"type": "message", "attachments": [{"contentType": +"application/vnd.microsoft.card.adaptive", "content": }]}`), which that +template posts one card per attachment. A bare card would make the run +succeed without posting anything. If you use a classic flow that posts the +request body itself as the card, set `TEAMS_WEBHOOK_FORMAT=card`. + +If the flow is turned off (Power Automate suspends flows, for example when a +connection expires), the webhook answers HTTP 400 and notifications are +retried for several hours before being marked failed. ### Email diff --git a/api/platform_notifications.py b/api/platform_notifications.py index ff6bd34..b9a4e2b 100644 --- a/api/platform_notifications.py +++ b/api/platform_notifications.py @@ -19,8 +19,10 @@ Channels (each is skipped when not configured): -- Teams: ``ADMIN_TEAMS_WEBHOOK_URL``, a Power Automate / Logic App flow that - posts the Adaptive Card it receives (the same pattern as the retrain alerts). +- Teams: ``ADMIN_TEAMS_WEBHOOK_URL``, a Teams Workflows / Power Automate + webhook. Cards are sent in the message envelope the "Send webhook alerts to + a channel" template expects (``TEAMS_WEBHOOK_FORMAT=card`` sends a bare + card for classic flows). - Email, either - SMTP: ``SMTP_HOST``, ``SMTP_PORT`` (587), ``SMTP_USERNAME``, ``SMTP_PASSWORD``, ``SMTP_FROM``, ``SMTP_SECURITY`` (starttls | ssl | none); @@ -281,11 +283,34 @@ def _post_json(url: str, payload: dict[str, Any], *, timeout: int = 15) -> None: raise RuntimeError(f"webhook returned HTTP {status}") +def wrap_for_teams_webhook(card: dict[str, Any]) -> dict[str, Any]: + """ + Wrap an Adaptive Card in the message envelope Teams Workflows expect. + + The "Send webhook alerts to a channel" template posts one card per entry + in ``attachments``; a bare card has none, so the run succeeds but posts + nothing. ``TEAMS_WEBHOOK_FORMAT=card`` sends the bare card instead, for a + classic flow that posts the request body itself. + """ + if os.getenv("TEAMS_WEBHOOK_FORMAT", "message").strip().lower() == "card": + return card + return { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": card, + } + ], + } + + def send_teams(notification: dict[str, Any]) -> None: url = os.getenv("ADMIN_TEAMS_WEBHOOK_URL", "").strip() if not url: raise ChannelNotConfigured("ADMIN_TEAMS_WEBHOOK_URL is not set") - _post_json(url, build_teams_card(notification)) + _post_json(url, wrap_for_teams_webhook(build_teams_card(notification))) def email_configured() -> bool: diff --git a/api/tests/test_admin_notifications_pg.py b/api/tests/test_admin_notifications_pg.py index cf9f447..6ee4e12 100644 --- a/api/tests/test_admin_notifications_pg.py +++ b/api/tests/test_admin_notifications_pg.py @@ -388,8 +388,12 @@ def fake_urlopen(request, timeout=0): self.assertEqual(self._state(key)[:4], ("sent", "sent", 1, True)) teams = next(body for url, body in posted if url.startswith("https://teams")) - self.assertEqual(teams["type"], "AdaptiveCard") - self.assertEqual(teams["actions"][0]["url"], "https://dt.example.test/admin/users") + # Teams Workflows webhooks post one card per attachment. + self.assertEqual(teams["type"], "message") + card = teams["attachments"][0]["content"] + self.assertEqual(teams["attachments"][0]["contentType"], "application/vnd.microsoft.card.adaptive") + self.assertEqual(card["type"], "AdaptiveCard") + self.assertEqual(card["actions"][0]["url"], "https://dt.example.test/admin/users") email = next(body for url, body in posted if url.startswith("https://mail")) self.assertIn(self.admin.email, email["to"]) self.assertNotIn(self.researcher.email, email["to"]) diff --git a/api/tests/test_platform_notifications.py b/api/tests/test_platform_notifications.py index 3621658..5f0fec6 100644 --- a/api/tests/test_platform_notifications.py +++ b/api/tests/test_platform_notifications.py @@ -62,6 +62,22 @@ def test_teams_card_link_only_when_base_url_configured(self): self.assertEqual(card["actions"][0]["url"], "https://dt.example.org/admin/role-requests") self.assertEqual(card["body"][3]["facts"][0], {"title": "Justification", "value": "I need \"admin\" & more"}) + def test_teams_payload_is_a_message_with_one_card_attachment(self): + env = {**CLEAR_ENV, "ADMIN_TEAMS_WEBHOOK_URL": "https://teams.example.org/hook", "TEAMS_WEBHOOK_FORMAT": ""} + with patch.dict(os.environ, env), patch.object(pn, "_post_json") as post: + pn.send_teams(_notification()) + payload = post.call_args.args[1] + self.assertEqual(payload["type"], "message") + self.assertEqual(len(payload["attachments"]), 1) + self.assertEqual(payload["attachments"][0]["contentType"], "application/vnd.microsoft.card.adaptive") + self.assertEqual(payload["attachments"][0]["content"]["type"], "AdaptiveCard") + + def test_bare_card_format_for_classic_flows(self): + env = {**CLEAR_ENV, "ADMIN_TEAMS_WEBHOOK_URL": "https://teams.example.org/hook", "TEAMS_WEBHOOK_FORMAT": "card"} + with patch.dict(os.environ, env), patch.object(pn, "_post_json") as post: + pn.send_teams(_notification()) + self.assertEqual(post.call_args.args[1]["type"], "AdaptiveCard") + def test_facts_drop_empty_values_and_truncate_long_ones(self): facts = pn._clean_facts([("Empty", ""), ("None", None), ("Long", "x" * 5000)]) self.assertEqual([title for title, _ in facts], ["Long"]) From 5bae7e4d24a9844c61cac0f69ccc10c9ce02d178 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Fri, 25 Sep 2026 12:51:16 -0400 Subject: [PATCH 2/2] Describe the Teams Workflows webhook format in the secrets example Co-Authored-By: Claude Opus 5.5 (1M context) --- geddes/k8s/01-secrets.yaml.example | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/geddes/k8s/01-secrets.yaml.example b/geddes/k8s/01-secrets.yaml.example index 6890eb6..ee06ce4 100644 --- a/geddes/k8s/01-secrets.yaml.example +++ b/geddes/k8s/01-secrets.yaml.example @@ -65,9 +65,12 @@ stringData: PG_SUPER_PASS: "REPLACE_WITH_POSTGRES_PASSWORD" # ── Admin notifications (api/ADMIN_NOTIFICATIONS.md) ── - # Teams: a Power Automate / Logic App flow URL that posts the Adaptive Card - # it receives to a Teams chat or channel. - # ADMIN_TEAMS_WEBHOOK_URL: "https://prod-00.westus.logic.azure.com/workflows/..." + # Teams: a Teams Workflows webhook ("Send webhook alerts to a channel"). + # Cards are sent as a Teams message with the card as an attachment; set + # TEAMS_WEBHOOK_FORMAT=card for a classic flow that posts the request body + # itself as the card. + # ADMIN_TEAMS_WEBHOOK_URL: "https://.environment.api.powerplatform.com/powerautomate/automations/direct/workflows/..." + # TEAMS_WEBHOOK_FORMAT: "message" # Email, either through an SMTP relay ... # SMTP_HOST: "smtp.example.edu" # SMTP_PORT: "587"