Skip to content

Commit 205cd17

Browse files
authored
Merge pull request #920 from fluxcd/ms-adaptive-card-provider
Add MS Adaptive Card payload to `msteams` Provider
2 parents b81755d + e0cf7a1 commit 205cd17

File tree

3 files changed

+314
-24
lines changed

3 files changed

+314
-24
lines changed

docs/spec/v1beta3/providers.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -350,23 +350,26 @@ stringData:
350350
##### Microsoft Teams
351351

352352
When `.spec.type` is set to `msteams`, the controller will send a payload for
353-
an [Event](events.md#event-structure) to the provided Microsoft Teams [Address](#address).
353+
an [Event](events.md#event-structure) to the provided [Address](#address). The address
354+
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
355+
the deprecated [Office 365 Connector](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/).
354356

355-
The Event will be formatted into a Microsoft Teams
356-
[connector message](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#example-of-connector-message),
357-
with the metadata attached as facts, and the involved object as summary.
357+
**Note:** If the Address host contains the suffix `.webhook.office.com`, the controller will imply that
358+
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.
359+
360+
In both cases the Event metadata is attached as facts, and the involved object as a summary/title.
358361
The severity of the Event is used to set the color of the message.
359362

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

365368
###### Microsoft Teams example
366369

367370
To configure a Provider for Microsoft Teams, create a Secret with [the
368-
`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),
369-
and a `msteams` Provider with a [Secret reference](#address-example).
371+
`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),
372+
and an `msteams` Provider with a [Secret reference](#secret-reference).
370373

371374
```yaml
372375
---
@@ -386,7 +389,7 @@ metadata:
386389
name: msteams-webhook
387390
namespace: default
388391
stringData:
389-
address: "https://xxx.webhook.office.com/..."
392+
address: https://prod-xxx.yyy.logic.azure.com:443/workflows/zzz/triggers/manual/paths/invoke?...
390393
```
391394

392395
##### DataDog

internal/notifier/teams.go

Lines changed: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,28 @@ import (
2121
"crypto/x509"
2222
"fmt"
2323
"net/url"
24+
"slices"
2425
"strings"
2526

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

30+
const (
31+
msTeamsSchemaDeprecatedConnector = iota
32+
msTeamsSchemaAdaptiveCard
33+
34+
// msAdaptiveCardVersion is the version of the MS Adaptive Card schema.
35+
// MS Teams currently supports only up to version 1.4:
36+
// https://community.powerplatform.com/forums/thread/details/?threadid=edde0a5d-e995-4ba3-96dc-2120fe51a4d0
37+
msAdaptiveCardVersion = "1.4"
38+
)
39+
2940
// MS Teams holds the incoming webhook URL
3041
type MSTeams struct {
3142
URL string
3243
ProxyURL string
3344
CertPool *x509.CertPool
45+
Schema int
3446
}
3547

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

69+
// The Adaptice Card payload structures below reflect this documentation:
70+
// 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
71+
72+
type msAdaptiveCardMessage struct {
73+
Type string `json:"type"`
74+
Attachments []msAdaptiveCardAttachment `json:"attachments"`
75+
}
76+
77+
type msAdaptiveCardAttachment struct {
78+
ContentType string `json:"contentType"`
79+
Content msAdaptiveCardContent `json:"content"`
80+
}
81+
82+
type msAdaptiveCardContent struct {
83+
Schema string `json:"$schema"`
84+
Type string `json:"type"`
85+
Version string `json:"version"`
86+
Body []msAdaptiveCardBodyElement `json:"body"`
87+
}
88+
89+
type msAdaptiveCardBodyElement struct {
90+
Type string `json:"type"`
91+
92+
*msAdaptiveCardContainer `json:",inline"`
93+
*msAdaptiveCardTextBlock `json:",inline"`
94+
*msAdaptiveCardFactSet `json:",inline"`
95+
}
96+
97+
type msAdaptiveCardContainer struct {
98+
Items []msAdaptiveCardBodyElement `json:"items,omitempty"`
99+
}
100+
101+
type msAdaptiveCardTextBlock struct {
102+
Text string `json:"text,omitempty"`
103+
Size string `json:"size,omitempty"`
104+
Weight string `json:"weight,omitempty"`
105+
Color string `json:"color,omitempty"`
106+
Wrap bool `json:"wrap,omitempty"`
107+
}
108+
109+
type msAdaptiveCardFactSet struct {
110+
Facts []msAdaptiveCardFact `json:"facts,omitempty"`
111+
}
112+
113+
type msAdaptiveCardFact struct {
114+
Title string `json:"title"`
115+
Value string `json:"value"`
116+
}
117+
57118
// NewMSTeams validates the MS Teams URL and returns a MSTeams object
58119
func NewMSTeams(hookURL string, proxyURL string, certPool *x509.CertPool) (*MSTeams, error) {
59-
_, err := url.ParseRequestURI(hookURL)
120+
u, err := url.ParseRequestURI(hookURL)
60121
if err != nil {
61122
return nil, fmt.Errorf("invalid MS Teams webhook URL %s: '%w'", hookURL, err)
62123
}
63124

64-
return &MSTeams{
125+
provider := &MSTeams{
65126
URL: hookURL,
66127
ProxyURL: proxyURL,
67128
CertPool: certPool,
68-
}, nil
129+
Schema: msTeamsSchemaAdaptiveCard,
130+
}
131+
132+
// Check if the webhook URL is the deprecated connector and update the schema accordingly.
133+
if strings.HasSuffix(strings.Split(u.Host, ":")[0], ".webhook.office.com") {
134+
provider.Schema = msTeamsSchemaDeprecatedConnector
135+
}
136+
137+
return provider, nil
69138
}
70139

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

147+
objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace)
148+
149+
var payload any
150+
switch s.Schema {
151+
case msTeamsSchemaDeprecatedConnector:
152+
payload = buildMSTeamsDeprecatedConnectorPayload(&event, objName)
153+
case msTeamsSchemaAdaptiveCard:
154+
payload = buildMSTeamsAdaptiveCardPayload(&event, objName)
155+
default:
156+
payload = buildMSTeamsAdaptiveCardPayload(&event, objName)
157+
}
158+
159+
err := postMessage(ctx, s.URL, s.ProxyURL, s.CertPool, payload)
160+
if err != nil {
161+
return fmt.Errorf("postMessage failed: %w", err)
162+
}
163+
164+
return nil
165+
}
166+
167+
func buildMSTeamsDeprecatedConnectorPayload(event *eventv1.Event, objName string) *MSTeamsPayload {
78168
facts := make([]MSTeamsField, 0, len(event.Metadata))
79169
for k, v := range event.Metadata {
80170
facts = append(facts, MSTeamsField{
@@ -83,8 +173,7 @@ func (s *MSTeams) Post(ctx context.Context, event eventv1.Event) error {
83173
})
84174
}
85175

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

105-
err := postMessage(ctx, s.URL, s.ProxyURL, s.CertPool, payload)
106-
if err != nil {
107-
return fmt.Errorf("postMessage failed: %w", err)
194+
return payload
195+
}
196+
197+
func buildMSTeamsAdaptiveCardPayload(event *eventv1.Event, objName string) *msAdaptiveCardMessage {
198+
// Prepare message, add red color to error messages.
199+
message := &msAdaptiveCardTextBlock{
200+
Text: event.Message,
201+
Wrap: true,
202+
}
203+
if event.Severity == eventv1.EventSeverityError {
204+
message.Color = "attention"
108205
}
109206

110-
return nil
207+
// Put "summary" first, then sort the rest of the metadata by key.
208+
facts := make([]msAdaptiveCardFact, 0, len(event.Metadata))
209+
const summaryKey = "summary"
210+
if summary, ok := event.Metadata[summaryKey]; ok {
211+
facts = append(facts, msAdaptiveCardFact{
212+
Title: summaryKey,
213+
Value: summary,
214+
})
215+
}
216+
metadataFirstIndex := len(facts)
217+
for k, v := range event.Metadata {
218+
if k == summaryKey {
219+
continue
220+
}
221+
facts = append(facts, msAdaptiveCardFact{
222+
Title: k,
223+
Value: v,
224+
})
225+
}
226+
slices.SortFunc(facts[metadataFirstIndex:], func(a, b msAdaptiveCardFact) int {
227+
return strings.Compare(a.Title, b.Title)
228+
})
229+
230+
// The card below was built with help from https://adaptivecards.io/designer using the Microsoft Teams host app.
231+
payload := &msAdaptiveCardMessage{
232+
Type: "message",
233+
Attachments: []msAdaptiveCardAttachment{
234+
{
235+
ContentType: "application/vnd.microsoft.card.adaptive",
236+
Content: msAdaptiveCardContent{
237+
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
238+
Type: "AdaptiveCard",
239+
Version: msAdaptiveCardVersion,
240+
Body: []msAdaptiveCardBodyElement{
241+
{
242+
Type: "Container",
243+
msAdaptiveCardContainer: &msAdaptiveCardContainer{
244+
Items: []msAdaptiveCardBodyElement{
245+
{
246+
Type: "TextBlock",
247+
msAdaptiveCardTextBlock: &msAdaptiveCardTextBlock{
248+
Text: objName,
249+
Size: "large",
250+
Weight: "bolder",
251+
Wrap: true,
252+
},
253+
},
254+
{
255+
Type: "TextBlock",
256+
msAdaptiveCardTextBlock: message,
257+
},
258+
{
259+
Type: "FactSet",
260+
msAdaptiveCardFactSet: &msAdaptiveCardFactSet{
261+
Facts: facts,
262+
},
263+
},
264+
},
265+
},
266+
},
267+
},
268+
},
269+
},
270+
},
271+
}
272+
273+
return payload
111274
}

0 commit comments

Comments
 (0)