From edcc31e9ff726775024c181c3099cadc49378cb8 Mon Sep 17 00:00:00 2001 From: Vadim Kharin Date: Fri, 19 Jun 2026 09:53:19 +0300 Subject: [PATCH 1/4] chore: add support pymsteams-workflow for msteams-notifier --- incubating/msteams-notifier/Dockerfile | 3 +- .../script/pymsteams-notifier.py | 93 ++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/incubating/msteams-notifier/Dockerfile b/incubating/msteams-notifier/Dockerfile index cfadd961c..b51bff2a2 100644 --- a/incubating/msteams-notifier/Dockerfile +++ b/incubating/msteams-notifier/Dockerfile @@ -3,13 +3,14 @@ FROM python:3.9.12-alpine3.15 ENV LANG C.UTF-8 ARG PYMSTEAMS_VERSION=0.1.9 +ARG PYMSTEAMS_WORKFLOW_VERSION=1.0.5 RUN apk update && \ apk upgrade && \ apk add --no-cache \ git \ nodejs && \ - pip install --no-cache-dir pymsteams==$PYMSTEAMS_VERSION + pip install --no-cache-dir pymsteams==$PYMSTEAMS_VERSION pymsteams-workflow==PYMSTEAMS_WORKFLOW_VERSION$ COPY script/pymsteams-notifier.py /pymsteams-notifier.py diff --git a/incubating/msteams-notifier/script/pymsteams-notifier.py b/incubating/msteams-notifier/script/pymsteams-notifier.py index e64206222..d9ace6319 100644 --- a/incubating/msteams-notifier/script/pymsteams-notifier.py +++ b/incubating/msteams-notifier/script/pymsteams-notifier.py @@ -1,9 +1,10 @@ import json import os import pymsteams +import pymsteams_workflow -def main(): +def msteamsNotifier(): cf_account = os.getenv('CF_ACCOUNT') cf_commit_author = os.getenv('CF_COMMIT_AUTHOR') cf_branch = os.getenv('CF_BRANCH') @@ -39,11 +40,11 @@ def main(): # Add button and link to the message. if msteams_link_url: myTeamsMessage.addLinkButton(msteams_link_text, msteams_link_url) - + # Add button and link to the message. if msteams_link_url_2: - myTeamsMessage.addLinkButton(msteams_link_text_2, msteams_link_url_2) - + myTeamsMessage.addLinkButton(msteams_link_text_2, msteams_link_url_2) + # create the section myMessageSection = pymsteams.cardsection() @@ -80,6 +81,90 @@ def main(): # send the message. myTeamsMessage.send() +def msteamsWorkflows(): + cf_account = os.getenv('CF_ACCOUNT') + cf_commit_author = os.getenv('CF_COMMIT_AUTHOR') + cf_branch = os.getenv('CF_BRANCH') + cf_build_url = os.getenv('CF_BUILD_URL') + cf_commit_message = os.getenv('CF_COMMIT_MESSAGE') + cf_commit_url = os.getenv('CF_COMMIT_URL') + cf_pull_request_action = os.getenv('CF_PULL_REQUEST_ACTION') + cf_pull_request_number = os.getenv('CF_PULL_REQUEST_NUMBER') + cf_status_message = os.getenv('CF_STATUS_MESSAGE', 'EXECUTED') + cf_repo_name = os.getenv('CF_REPO_NAME') + cf_revision = os.getenv('CF_REVISION') + msteams_activity_image = os.getenv('MSTEAMS_ACTIVITY_IMAGE', 'https://steps.codefresh.io/assets/img/loading.gif') + mstreams_activity_subtitle = os.getenv('MSTEAMS_ACTIVITY_SUBTITLE', 'Build Status: {}'.format(cf_status_message)) + msteams_activity_text = os.getenv('MSTEAMS_ACTIVITY_TEXT', 'Additional Information Below') + msteams_link_text = os.getenv('MSTEAMS_LINK_TEXT', 'Codefresh Build Logs') + msteams_link_text_2 = os.getenv('MSTEAMS_LINK_TEXT_2', 'Commit Information') + msteams_link_url = os.getenv('MSTEAMS_LINK_URL', cf_build_url) + msteams_link_url_2 = os.getenv('MSTEAMS_LINK_URL_2', cf_commit_url) + msteams_new_workflow_url = os.getenv('MSTEAMS_NEW_WORKFLOW_URL') + msteams_text = os.getenv('MSTEAMS_TEXT', 'Codefresh Account: {}'.format(cf_account)) + msteams_title = os.getenv('MSTEAMS_TITLE', 'Codefresh Build Notification') + msteams_workflow_url = os.getenv('MSTEAMS_WORKFLOW_URL') + + myTeamsMessage = pymsteams_workflow.connectorcard(msteams_workflow_url) + # Add title to the message + myTeamsMessage.title(msteams_title) + + # Add text to the message. + myTeamsMessage.text(msteams_text) + + # Add button and link to the message. + if msteams_link_url: + myTeamsMessage.addLinkButton(msteams_link_text, msteams_link_url) + + # Add button and link to the message. + if msteams_link_url_2: + myTeamsMessage.addLinkButton(msteams_link_text_2, msteams_link_url_2) + + # create the section + myMessageSection = pymsteams.cardsection() + + # Activity Elements + myMessageSection.activitySubtitle(mstreams_activity_subtitle) + myMessageSection.activityImage(msteams_activity_image) + myMessageSection.activityText(msteams_activity_text) + + # Facts are key value pairs displayed in a list. + if cf_repo_name: + myMessageSection.addFact("GIT Repository", cf_repo_name) + if cf_branch: + myMessageSection.addFact("GIT Branch", cf_branch) + if cf_revision: + myMessageSection.addFact("GIT Revision", cf_revision) + if cf_commit_author: + myMessageSection.addFact("Commit Author", cf_commit_author) + if cf_commit_message: + myMessageSection.addFact("Commit Message", cf_commit_message) + if cf_pull_request_number: + myMessageSection.addFact("Pull Request Number", cf_pull_request_number) + if cf_pull_request_action: + myMessageSection.addFact("Pull Request Action", cf_pull_request_action) + + # Add your section to the connector card object before sending + myTeamsMessage.addSection(myMessageSection) + + # Send to additional room + #if msteams_new_workflow_url: + # myTeamsMessage.newhookurl(msteams_new_workflow_url) + + myTeamsMessage.printme() + + # send the message. + myTeamsMessage.send() + + + +def main(): + useWorkflows = os.getenv("USE_POWER_AUTOMATE_WORKFLOWS", "false").lower() + if useWorkflows == "true": + msteamsWorkflows() + else: + msteams_notifier() + if __name__ == "__main__": main() From 4ad911b169b5f86e6d2384a57f80bf3297824f36 Mon Sep 17 00:00:00 2001 From: vitaliichyrka Date: Tue, 23 Jun 2026 08:47:40 +0300 Subject: [PATCH 2/4] Vendored pymsteams-workflow to avoid module name collision and updated scripts for integration. --- incubating/msteams-notifier/Dockerfile | 7 +- .../script/pymsteams-notifier.py | 8 +- .../script/pymsteams_workflow.py | 188 ++++++++++++++++++ 3 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 incubating/msteams-notifier/script/pymsteams_workflow.py diff --git a/incubating/msteams-notifier/Dockerfile b/incubating/msteams-notifier/Dockerfile index b51bff2a2..071b01f35 100644 --- a/incubating/msteams-notifier/Dockerfile +++ b/incubating/msteams-notifier/Dockerfile @@ -3,15 +3,18 @@ FROM python:3.9.12-alpine3.15 ENV LANG C.UTF-8 ARG PYMSTEAMS_VERSION=0.1.9 -ARG PYMSTEAMS_WORKFLOW_VERSION=1.0.5 RUN apk update && \ apk upgrade && \ apk add --no-cache \ git \ nodejs && \ - pip install --no-cache-dir pymsteams==$PYMSTEAMS_VERSION pymsteams-workflow==PYMSTEAMS_WORKFLOW_VERSION$ + pip install --no-cache-dir pymsteams==$PYMSTEAMS_VERSION +# pymsteams-workflow is vendored locally (see script/pymsteams_workflow.py) because +# the PyPI package installs under the module name "pymsteams" and collides with the +# legacy webhook client above. COPY script/pymsteams-notifier.py /pymsteams-notifier.py +COPY script/pymsteams_workflow.py /pymsteams_workflow.py ENTRYPOINT ["python", "/pymsteams-notifier.py"] diff --git a/incubating/msteams-notifier/script/pymsteams-notifier.py b/incubating/msteams-notifier/script/pymsteams-notifier.py index d9ace6319..b96faf351 100644 --- a/incubating/msteams-notifier/script/pymsteams-notifier.py +++ b/incubating/msteams-notifier/script/pymsteams-notifier.py @@ -121,7 +121,7 @@ def msteamsWorkflows(): myTeamsMessage.addLinkButton(msteams_link_text_2, msteams_link_url_2) # create the section - myMessageSection = pymsteams.cardsection() + myMessageSection = pymsteams_workflow.cardsection() # Activity Elements myMessageSection.activitySubtitle(mstreams_activity_subtitle) @@ -147,10 +147,6 @@ def msteamsWorkflows(): # Add your section to the connector card object before sending myTeamsMessage.addSection(myMessageSection) - # Send to additional room - #if msteams_new_workflow_url: - # myTeamsMessage.newhookurl(msteams_new_workflow_url) - myTeamsMessage.printme() # send the message. @@ -163,7 +159,7 @@ def main(): if useWorkflows == "true": msteamsWorkflows() else: - msteams_notifier() + msteamsNotifier() if __name__ == "__main__": diff --git a/incubating/msteams-notifier/script/pymsteams_workflow.py b/incubating/msteams-notifier/script/pymsteams_workflow.py new file mode 100644 index 000000000..1fecf99db --- /dev/null +++ b/incubating/msteams-notifier/script/pymsteams_workflow.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +"""Vendored Power Automate Workflow connector for MS Teams. + +The published ``pymsteams-workflow`` package installs its code under the module +name ``pymsteams``, which collides with the regular ``pymsteams`` package (both +own ``pymsteams/__init__.py``). Since this notifier needs BOTH the legacy +webhook client (``pymsteams``) and the Workflow client at the same time, the +Workflow client is vendored here under a distinct module name so the two can +coexist. + +It builds a Microsoft Adaptive Card and POSTs it to a Power Automate Workflow +"When a Teams webhook request is received" trigger URL. +""" + +import json +from urllib.parse import urlparse, parse_qs + +import requests + + +class TeamsWebhookException(Exception): + """Custom exception for a failed workflow call.""" + pass + + +class cardsection: + """Accumulates section content and renders it as Adaptive Card blocks.""" + + def __init__(self): + self.activity_subtitle = None + self.activity_image = None + self.activity_text = None + self.facts = [] + + def activitySubtitle(self, subtitle): + self.activity_subtitle = subtitle + return self + + def activityImage(self, image_url): + self.activity_image = image_url + return self + + def activityText(self, text): + self.activity_text = text + return self + + def addFact(self, name, value): + self.facts.append({"title": str(name), "value": str(value)}) + return self + + def as_blocks(self): + blocks = [] + + if self.activity_image or self.activity_subtitle: + columns = [] + if self.activity_image: + columns.append({ + "type": "Column", + "width": "auto", + "items": [{ + "type": "Image", + "url": self.activity_image, + "size": "Small", + }], + }) + if self.activity_subtitle: + columns.append({ + "type": "Column", + "width": "stretch", + "verticalContentAlignment": "Center", + "items": [{ + "type": "TextBlock", + "text": self.activity_subtitle, + "weight": "Bolder", + "wrap": True, + }], + }) + blocks.append({"type": "ColumnSet", "columns": columns}) + + if self.activity_text: + blocks.append({ + "type": "TextBlock", + "text": self.activity_text, + "wrap": True, + }) + + if self.facts: + blocks.append({"type": "FactSet", "facts": self.facts}) + + return blocks + + +class connectorcard: + def __init__(self, url): + # Parse the URL to extract the base URL and query parameters. + parsed_url = urlparse(url) + self.hookurl = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + query_params = parse_qs(parsed_url.query) + + self.params = { + 'api-version': query_params.get('api-version', ['2016-06-01'])[0], + 'sp': query_params.get('sp', ['/triggers/manual/run'])[0], + 'sv': query_params.get('sv', ['1.0'])[0], + 'sig': query_params.get('sig', [''])[0], + } + + self.payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [], + "msteams": {"width": "full"}, + }, + } + ], + } + self.proxies = None + self.http_timeout = 60 + self.verify = True + self.last_http_response = None + + def _body(self): + return self.payload["attachments"][0]["content"]["body"] + + def title(self, mtitle): + self._body().insert(0, { + "type": "TextBlock", + "text": mtitle, + "style": "heading", + "weight": "Bolder", + "size": "Large", + "wrap": True, + "id": "title", + }) + return self + + def text(self, mtext): + self._body().append({ + "type": "TextBlock", + "text": mtext, + "wrap": True, + "id": "body", + }) + return self + + def addLinkButton(self, button_text, button_url): + actions = self.payload["attachments"][0]["content"].setdefault("actions", []) + actions.append({ + "type": "Action.OpenUrl", + "title": button_text, + "url": button_url, + }) + return self + + def addSection(self, section): + self._body().extend(section.as_blocks()) + return self + + def printme(self): + print(json.dumps(self.payload, indent=4)) + + def send(self): + headers = { + 'User-Agent': 'MSTeams', + 'Content-Type': 'application/json', + } + try: + r = requests.post( + self.hookurl, + params=self.params, + data=json.dumps(self.payload), + headers=headers, + proxies=self.proxies, + timeout=self.http_timeout, + verify=self.verify, + ) + self.last_http_response = r + if r.status_code in (requests.codes.ok, requests.codes.accepted): + return True + raise TeamsWebhookException(r.text) + except requests.exceptions.RequestException as e: + raise TeamsWebhookException(str(e)) \ No newline at end of file From 47c6fce88d302acfc6774e51349c584d01228436 Mon Sep 17 00:00:00 2001 From: vitaliichyrka Date: Tue, 23 Jun 2026 09:16:34 +0300 Subject: [PATCH 3/4] Added support for Power Automate workflows; updated documentation and Docker base image to Python 3.14.6-alpine3.23. --- incubating/msteams-notifier/Dockerfile | 2 +- incubating/msteams-notifier/README.md | 33 +++++++++++++++++++++++++ incubating/msteams-notifier/example.yml | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/incubating/msteams-notifier/Dockerfile b/incubating/msteams-notifier/Dockerfile index 071b01f35..7ce83f2c8 100644 --- a/incubating/msteams-notifier/Dockerfile +++ b/incubating/msteams-notifier/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.12-alpine3.15 +FROM python:3.14.6-alpine3.23 ENV LANG C.UTF-8 diff --git a/incubating/msteams-notifier/README.md b/incubating/msteams-notifier/README.md index dbdfaa39c..8858ca0c9 100644 --- a/incubating/msteams-notifier/README.md +++ b/incubating/msteams-notifier/README.md @@ -4,6 +4,35 @@ Codefresh Pipeline Step to Send Notification to Microsoft Teams EXAMPLE CARD ![Microsoft Teams Example Card](/incubating/msteams-notifier/images/msteams_example_card.png) +> **⚠️ Office 365 Connectors are being retired by Microsoft.** +> The legacy Incoming Webhook (`MSTEAMS_WEBHOOK_URL`) relies on Office 365 connectors, which Microsoft is +> retiring within Microsoft Teams. New connector webhooks can no longer be created and existing ones will +> stop working. See the official announcement for timelines and details: +> https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/ +> +> Use **Power Automate Workflows** instead (see the section below). + +## Power Automate Workflows (recommended) + +Microsoft's replacement for retired Office 365 connectors is a Power Automate Workflow with the +**"Post to a channel when a webhook request is received"** trigger. To use this mode you must: + +1. Set `USE_POWER_AUTOMATE_WORKFLOWS=true` to switch the step from the legacy webhook to the Workflow client. +2. Provide the Workflow trigger URL via `MSTEAMS_WORKFLOW_URL` (this replaces `MSTEAMS_WEBHOOK_URL`). + +``` yaml + MSTeamsNotification: + image: codefreshplugins/cfstep-msteams-notifier:latest + environment: + - USE_POWER_AUTOMATE_WORKFLOWS=true + - MSTEAMS_WORKFLOW_URL=https://prod-XX.westus.logic.azure.com:443/workflows/... +``` + +`USE_POWER_AUTOMATE_WORKFLOWS` defaults to `false`; when it is not `true` the step falls back to the legacy +webhook behaviour described below. + +## Legacy Incoming Webhook (deprecated) + YAML Step ``` yaml MSTeamsNotification: @@ -23,6 +52,10 @@ Replace the MSTEAMS_WEBHOOK_URL value in the Basic YAML example with the URL pro This is the only required variable for the notification to send out on a pipeline execution. +> **Note:** This mode is deprecated. Because Office 365 connectors are being retired +> ([announcement](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)), +> prefer the Power Automate Workflows mode above for new pipelines. + TODO: Add links to Codefresh imagery for connector or card usage. Want to send specific notifications based on the pipeline failing or succeeding? diff --git a/incubating/msteams-notifier/example.yml b/incubating/msteams-notifier/example.yml index fe5bc7457..e33347fb3 100644 --- a/incubating/msteams-notifier/example.yml +++ b/incubating/msteams-notifier/example.yml @@ -3,4 +3,5 @@ steps: MSTeamsNotification: image: codefreshplugins/cfstep-msteams-notifier:latest environment: + - USE_POWER_AUTOMATE_WORKFLOWS=true - MSTEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/37a4ea3d... \ No newline at end of file From 2ed7f72c1f3cb4751f9eca89f724266cd282e808 Mon Sep 17 00:00:00 2001 From: vitaliichyrka Date: Tue, 23 Jun 2026 11:19:58 +0300 Subject: [PATCH 4/4] Validate and sanitize `MSTEAMS_WORKFLOW_URL` to handle common misconfigurations like surrounding quotes or whitespace. --- .../msteams-notifier/script/pymsteams_workflow.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/incubating/msteams-notifier/script/pymsteams_workflow.py b/incubating/msteams-notifier/script/pymsteams_workflow.py index 1fecf99db..1a1bd3115 100644 --- a/incubating/msteams-notifier/script/pymsteams_workflow.py +++ b/incubating/msteams-notifier/script/pymsteams_workflow.py @@ -92,8 +92,23 @@ def as_blocks(self): class connectorcard: def __init__(self, url): + # Tolerate surrounding whitespace and quotes that commonly leak in from + # mis-quoted CI environment variables (e.g. MSTEAMS_WORKFLOW_URL="..."). + url = (url or "").strip().strip('"').strip("'").strip() + if not url: + raise TeamsWebhookException( + "Workflow URL is empty. Set the MSTEAMS_WORKFLOW_URL environment " + "variable to the Power Automate trigger URL (without surrounding quotes)." + ) + # Parse the URL to extract the base URL and query parameters. parsed_url = urlparse(url) + if not parsed_url.scheme or not parsed_url.netloc: + raise TeamsWebhookException( + f"Invalid workflow URL {url!r}: expected an https:// URL. Check that " + "MSTEAMS_WORKFLOW_URL is set correctly and is not wrapped in quotes." + ) + self.hookurl = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" query_params = parse_qs(parsed_url.query)