From df4cd98a9eb28631761a4d3b63c44495d713fa64 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sat, 11 Jul 2026 23:28:25 +0200 Subject: [PATCH] feat(microsoft-teams): new Microsoft Teams notification integration Incoming-webhook / Power Automate Workflows, 3 commands: send message, send Adaptive Card (title/text/facts/action button). Webhook-URL auth, stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/microsoft-teams/manifest.yaml | 49 ++++++++++ .../microsoft-teams/scripts/send_card.py | 89 +++++++++++++++++++ .../microsoft-teams/scripts/send_message.py | 62 +++++++++++++ .../scripts/test_connection.py | 58 ++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 integrations/microsoft-teams/manifest.yaml create mode 100644 integrations/microsoft-teams/scripts/send_card.py create mode 100644 integrations/microsoft-teams/scripts/send_message.py create mode 100644 integrations/microsoft-teams/scripts/test_connection.py diff --git a/integrations/microsoft-teams/manifest.yaml b/integrations/microsoft-teams/manifest.yaml new file mode 100644 index 0000000..85bab7d --- /dev/null +++ b/integrations/microsoft-teams/manifest.yaml @@ -0,0 +1,49 @@ +id: microsoft_teams +name: Microsoft Teams +version: 1.0.0 +description: "Microsoft Teams (incoming webhook / Power Automate Workflows) — SOC notification: post plain messages and rich Adaptive Cards (title, text, facts, optional action button) to a Teams channel. Webhook-URL authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: send message, send Adaptive Card." +category: notification + +# Per-instance configuration. The webhook URL is created in Teams +# (Workflows / 'Post to a channel when a webhook request is received', or a +# classic Incoming Webhook connector). All messages POST to this URL. +config_schema: + properties: + webhook_url: + type: string + description: "Teams incoming webhook / Workflows URL" + x-soar-sensitive: true + required: + - webhook_url + +commands: + - id: send_message + name: teams-send-message + description: "Post a plain-text message to the Teams channel." + inputs_schema: + properties: + text: { type: string, description: "Message text (Markdown supported)" } + required: [text] + outputs_schema: { properties: {} } + - id: send_card + name: teams-send-card + description: "Post an Adaptive Card built from a title, body text, optional facts, and an optional action button." + inputs_schema: + properties: + title: { type: string, description: "Card title" } + text: { type: string, description: "Card body text" } + facts: { type: string, description: "Optional key=value pairs, comma-separated (e.g. 'Severity=High,Host=web01')" } + action_title: { type: string, description: "Optional button label" } + action_url: { type: string, description: "Optional button URL (required if action_title is set)" } + required: [title] + outputs_schema: { properties: {} } + + - id: test_connection + name: teams-test-connection + description: "Post a small connection-test message to verify the webhook (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/microsoft-teams/scripts/send_card.py b/integrations/microsoft-teams/scripts/send_card.py new file mode 100644 index 0000000..0aa6c74 --- /dev/null +++ b/integrations/microsoft-teams/scripts/send_card.py @@ -0,0 +1,89 @@ +import json, os, sys, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def post(cfg, payload): + url = str(cfg.get("webhook_url", "")) + if not url: + raise Exception("webhook_url is not configured") + data = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + r.read() + return r.status + + +def adaptive_card(body_blocks, actions=None): + card = { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.4", + "body": body_blocks, + } + if actions: + card["actions"] = actions + return { + "type": "message", + "attachments": [ + {"contentType": "application/vnd.microsoft.card.adaptive", "content": card} + ], + } + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + title = str(inputs.get("title", "")).strip() + if not title: + raise Exception("title is required") + + text = str(inputs.get("text", "")).strip() + facts_raw = str(inputs.get("facts", "")).strip() + action_title = str(inputs.get("action_title", "")).strip() + action_url = str(inputs.get("action_url", "")).strip() + + if action_title and not action_url: + raise Exception("action_url is required when action_title is set") + + blocks = [{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Large", "wrap": True}] + + if text: + blocks.append({"type": "TextBlock", "text": text, "wrap": True}) + + if facts_raw: + facts = [] + for pair in facts_raw.split(","): + if "=" not in pair: + continue + k, v = pair.split("=", 1) + facts.append({"title": k.strip(), "value": v.strip()}) + if facts: + blocks.append({"type": "FactSet", "facts": facts}) + + actions = None + if action_title and action_url: + actions = [{"type": "Action.OpenUrl", "title": action_title, "url": action_url}] + + payload = adaptive_card(blocks, actions) + status = post(cfg, payload) + return {"ok": True, "status": status} + + +_run(main) diff --git a/integrations/microsoft-teams/scripts/send_message.py b/integrations/microsoft-teams/scripts/send_message.py new file mode 100644 index 0000000..ce660b5 --- /dev/null +++ b/integrations/microsoft-teams/scripts/send_message.py @@ -0,0 +1,62 @@ +import json, os, sys, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def post(cfg, payload): + url = str(cfg.get("webhook_url", "")) + if not url: + raise Exception("webhook_url is not configured") + data = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + r.read() + return r.status + + +def adaptive_card(body_blocks, actions=None): + card = { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.4", + "body": body_blocks, + } + if actions: + card["actions"] = actions + return { + "type": "message", + "attachments": [ + {"contentType": "application/vnd.microsoft.card.adaptive", "content": card} + ], + } + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + text = str(inputs.get("text", "")).strip() + if not text: + raise Exception("text is required") + + payload = adaptive_card([{"type": "TextBlock", "text": text, "wrap": True}]) + status = post(cfg, payload) + return {"ok": True, "status": status} + + +_run(main) diff --git a/integrations/microsoft-teams/scripts/test_connection.py b/integrations/microsoft-teams/scripts/test_connection.py new file mode 100644 index 0000000..9a97d5f --- /dev/null +++ b/integrations/microsoft-teams/scripts/test_connection.py @@ -0,0 +1,58 @@ +import json, os, sys, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def post(cfg, payload): + url = str(cfg.get("webhook_url", "")) + if not url: + raise Exception("webhook_url is not configured") + data = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + r.read() + return r.status + + +def adaptive_card(body_blocks, actions=None): + card = { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.4", + "body": body_blocks, + } + if actions: + card["actions"] = actions + return { + "type": "message", + "attachments": [ + {"contentType": "application/vnd.microsoft.card.adaptive", "content": card} + ], + } + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + payload = adaptive_card([{"type": "TextBlock", "text": "Riposte SOAR connection test", "wrap": True}]) + status = post(cfg, payload) + return {"ok": True, "status": status} + + +_run(main)