Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MS Adaptive Card payload to msteams Provider #920

Merged
merged 1 commit into from
Sep 12, 2024
Merged
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
19 changes: 11 additions & 8 deletions docs/spec/v1beta3/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,23 +350,26 @@ stringData:
##### Microsoft Teams

When `.spec.type` is set to `msteams`, the controller will send a payload for
an [Event](events.md#event-structure) to the provided Microsoft Teams [Address](#address).
an [Event](events.md#event-structure) to the provided [Address](#address). The address
may be a [Microsoft Teams Incoming Webhook Workflow](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498), or
the deprecated [Office 365 Connector](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/).

The Event will be formatted into a Microsoft Teams
[connector message](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#example-of-connector-message),
with the metadata attached as facts, and the involved object as summary.
**Note:** If the Address host contains the suffix `.webhook.office.com`, the controller will imply that
the backend is the deprecated Office 365 Connector and is expecting the Event in the [connector message](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#example-of-connector-message) format. Otherwise, the controller will format the Event as a [Microsoft Adaptive Card](https://adaptivecards.io/explorer/) message.

In both cases the Event metadata is attached as facts, and the involved object as a summary/title.
The severity of the Event is used to set the color of the message.

This Provider type supports the configuration of a [proxy URL](#https-proxy)
and/or [TLS certificates](#tls-certificates), but lacks support for
configuring a [Channel](#channel). This can be configured during the
creation of the incoming webhook in Microsoft Teams.
creation of the Incoming Webhook Workflow in Microsoft Teams.

###### Microsoft Teams example

To configure a Provider for Microsoft Teams, create a Secret with [the
`address`](#address-example) set to the [webhook URL](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook#create-incoming-webhooks-1),
and a `msteams` Provider with a [Secret reference](#address-example).
`address`](#address-example) set to the [webhook URL](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498),
and an `msteams` Provider with a [Secret reference](#secret-reference).
darkowlzz marked this conversation as resolved.
Show resolved Hide resolved

```yaml
---
Expand All @@ -386,7 +389,7 @@ metadata:
name: msteams-webhook
namespace: default
stringData:
address: "https://xxx.webhook.office.com/..."
address: https://prod-xxx.yyy.logic.azure.com:443/workflows/zzz/triggers/manual/paths/invoke?...
```

##### DataDog
Expand Down
181 changes: 172 additions & 9 deletions internal/notifier/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,28 @@ import (
"crypto/x509"
"fmt"
"net/url"
"slices"
"strings"

eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
)

const (
msTeamsSchemaDeprecatedConnector = iota
msTeamsSchemaAdaptiveCard

// msAdaptiveCardVersion is the version of the MS Adaptive Card schema.
// MS Teams currently supports only up to version 1.4:
// https://community.powerplatform.com/forums/thread/details/?threadid=edde0a5d-e995-4ba3-96dc-2120fe51a4d0
msAdaptiveCardVersion = "1.4"
)

// MS Teams holds the incoming webhook URL
type MSTeams struct {
URL string
ProxyURL string
CertPool *x509.CertPool
Schema int
}

// MSTeamsPayload holds the message card data
Expand All @@ -54,18 +66,75 @@ type MSTeamsField struct {
Value string `json:"value"`
}

// The Adaptice Card payload structures below reflect this documentation:
// https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL%2Ctext1#send-adaptive-cards-using-an-incoming-webhook

type msAdaptiveCardMessage struct {
Type string `json:"type"`
Attachments []msAdaptiveCardAttachment `json:"attachments"`
}

type msAdaptiveCardAttachment struct {
ContentType string `json:"contentType"`
Content msAdaptiveCardContent `json:"content"`
}

type msAdaptiveCardContent struct {
Schema string `json:"$schema"`
Type string `json:"type"`
Version string `json:"version"`
Body []msAdaptiveCardBodyElement `json:"body"`
}

type msAdaptiveCardBodyElement struct {
Type string `json:"type"`

*msAdaptiveCardContainer `json:",inline"`
*msAdaptiveCardTextBlock `json:",inline"`
*msAdaptiveCardFactSet `json:",inline"`
}

type msAdaptiveCardContainer struct {
Items []msAdaptiveCardBodyElement `json:"items,omitempty"`
}

type msAdaptiveCardTextBlock struct {
Text string `json:"text,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Color string `json:"color,omitempty"`
Wrap bool `json:"wrap,omitempty"`
}

type msAdaptiveCardFactSet struct {
Facts []msAdaptiveCardFact `json:"facts,omitempty"`
}

type msAdaptiveCardFact struct {
Title string `json:"title"`
Value string `json:"value"`
}

// NewMSTeams validates the MS Teams URL and returns a MSTeams object
func NewMSTeams(hookURL string, proxyURL string, certPool *x509.CertPool) (*MSTeams, error) {
_, err := url.ParseRequestURI(hookURL)
u, err := url.ParseRequestURI(hookURL)
if err != nil {
return nil, fmt.Errorf("invalid MS Teams webhook URL %s: '%w'", hookURL, err)
}

return &MSTeams{
provider := &MSTeams{
URL: hookURL,
ProxyURL: proxyURL,
CertPool: certPool,
}, nil
Schema: msTeamsSchemaAdaptiveCard,
}

// Check if the webhook URL is the deprecated connector and update the schema accordingly.
if strings.HasSuffix(strings.Split(u.Host, ":")[0], ".webhook.office.com") {
provider.Schema = msTeamsSchemaDeprecatedConnector
}

return provider, nil
}

// Post MS Teams message
Expand All @@ -75,6 +144,27 @@ func (s *MSTeams) Post(ctx context.Context, event eventv1.Event) error {
return nil
}

objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace)

var payload any
switch s.Schema {
case msTeamsSchemaDeprecatedConnector:
payload = buildMSTeamsDeprecatedConnectorPayload(&event, objName)
case msTeamsSchemaAdaptiveCard:
payload = buildMSTeamsAdaptiveCardPayload(&event, objName)
default:
payload = buildMSTeamsAdaptiveCardPayload(&event, objName)
}

err := postMessage(ctx, s.URL, s.ProxyURL, s.CertPool, payload)
if err != nil {
return fmt.Errorf("postMessage failed: %w", err)
}

return nil
}

func buildMSTeamsDeprecatedConnectorPayload(event *eventv1.Event, objName string) *MSTeamsPayload {
facts := make([]MSTeamsField, 0, len(event.Metadata))
for k, v := range event.Metadata {
facts = append(facts, MSTeamsField{
Expand All @@ -83,8 +173,7 @@ func (s *MSTeams) Post(ctx context.Context, event eventv1.Event) error {
})
}

objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace)
payload := MSTeamsPayload{
payload := &MSTeamsPayload{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: "0076D7",
Expand All @@ -102,10 +191,84 @@ func (s *MSTeams) Post(ctx context.Context, event eventv1.Event) error {
payload.ThemeColor = "FF0000"
}

err := postMessage(ctx, s.URL, s.ProxyURL, s.CertPool, payload)
if err != nil {
return fmt.Errorf("postMessage failed: %w", err)
return payload
}

func buildMSTeamsAdaptiveCardPayload(event *eventv1.Event, objName string) *msAdaptiveCardMessage {
// Prepare message, add red color to error messages.
message := &msAdaptiveCardTextBlock{
Text: event.Message,
Wrap: true,
}
if event.Severity == eventv1.EventSeverityError {
message.Color = "attention"
}

return nil
// Put "summary" first, then sort the rest of the metadata by key.
facts := make([]msAdaptiveCardFact, 0, len(event.Metadata))
const summaryKey = "summary"
if summary, ok := event.Metadata[summaryKey]; ok {
facts = append(facts, msAdaptiveCardFact{
Title: summaryKey,
Value: summary,
})
}
metadataFirstIndex := len(facts)
for k, v := range event.Metadata {
if k == summaryKey {
continue
}
facts = append(facts, msAdaptiveCardFact{
Title: k,
Value: v,
})
}
slices.SortFunc(facts[metadataFirstIndex:], func(a, b msAdaptiveCardFact) int {
return strings.Compare(a.Title, b.Title)
})

// The card below was built with help from https://adaptivecards.io/designer using the Microsoft Teams host app.
payload := &msAdaptiveCardMessage{
Type: "message",
Attachments: []msAdaptiveCardAttachment{
{
ContentType: "application/vnd.microsoft.card.adaptive",
Content: msAdaptiveCardContent{
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: msAdaptiveCardVersion,
Body: []msAdaptiveCardBodyElement{
{
Type: "Container",
msAdaptiveCardContainer: &msAdaptiveCardContainer{
Items: []msAdaptiveCardBodyElement{
{
Type: "TextBlock",
msAdaptiveCardTextBlock: &msAdaptiveCardTextBlock{
Text: objName,
Size: "large",
Weight: "bolder",
Wrap: true,
},
},
{
Type: "TextBlock",
msAdaptiveCardTextBlock: message,
},
{
Type: "FactSet",
msAdaptiveCardFactSet: &msAdaptiveCardFactSet{
Facts: facts,
},
},
},
},
},
},
},
},
},
}

return payload
}
Loading
Loading