Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion incubating/msteams-notifier/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9.12-alpine3.15
FROM python:3.14.6-alpine3.23

ENV LANG C.UTF-8

Expand All @@ -11,6 +11,10 @@ RUN apk update && \
nodejs && \
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"]
33 changes: 33 additions & 0 deletions incubating/msteams-notifier/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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?
Expand Down
1 change: 1 addition & 0 deletions incubating/msteams-notifier/example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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...
89 changes: 85 additions & 4 deletions incubating/msteams-notifier/script/pymsteams-notifier.py
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -80,6 +81,86 @@ 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_workflow.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)

myTeamsMessage.printme()

# send the message.
myTeamsMessage.send()



def main():
useWorkflows = os.getenv("USE_POWER_AUTOMATE_WORKFLOWS", "false").lower()
if useWorkflows == "true":
msteamsWorkflows()
else:
msteamsNotifier()


if __name__ == "__main__":
main()
203 changes: 203 additions & 0 deletions incubating/msteams-notifier/script/pymsteams_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/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):
# 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)

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))
Loading