Skip to content

Commit

Permalink
Merge pull request #90 from grafana/santihernandezc/use_adaptive_card…
Browse files Browse the repository at this point in the history
…s_ms_teams

Use Adaptive Cards in MS Teams integration
  • Loading branch information
santihernandezc authored Sep 6, 2024
2 parents 66ec17e + b2665b0 commit 60521fa
Show file tree
Hide file tree
Showing 4 changed files with 455 additions and 38 deletions.
155 changes: 155 additions & 0 deletions notify/msteams/adaptive_cards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright 2024 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package msteams

import "encoding/json"

// AdaptiveCardsMessage represents a message for adaptive cards.
type AdaptiveCardsMessage struct {
Attachments []AdaptiveCardsAttachment `json:"attachments"`
Summary string `json:"summary,omitempty"` // Summary is the text shown in notifications
Type string `json:"type"`
}

// NewAdaptiveCardsMessage returns a message prepared for adaptive cards.
// https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#send-adaptive-cards-using-an-incoming-webhook
// more info https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#microsoft-teams-webhook
func NewAdaptiveCardsMessage(card AdaptiveCard) AdaptiveCardsMessage {
return AdaptiveCardsMessage{
Attachments: []AdaptiveCardsAttachment{{
ContentType: "application/vnd.microsoft.card.adaptive",
Content: card,
}},
Type: "message",
}
}

// AdaptiveCardsAttachment contains an adaptive card.
type AdaptiveCardsAttachment struct {
Content AdaptiveCard `json:"content"`
ContentType string `json:"contentType"`
ContentURL string `json:"contentUrl,omitempty"`
}

// AdaptiveCard repesents an Adaptive Card.
// https://adaptivecards.io/explorer/AdaptiveCard.html
type AdaptiveCard struct {
Body []AdaptiveCardItem
Schema string
Type string
Version string
}

// NewAdaptiveCard returns a prepared Adaptive Card.
func NewAdaptiveCard() AdaptiveCard {
return AdaptiveCard{
Body: make([]AdaptiveCardItem, 0),
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.4",
}
}

func (c *AdaptiveCard) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Body []AdaptiveCardItem `json:"body"`
Schema string `json:"$schema"`
Type string `json:"type"`
Version string `json:"version"`
MsTeams map[string]interface{} `json:"msTeams,omitempty"`
}{
Body: c.Body,
Schema: c.Schema,
Type: c.Type,
Version: c.Version,
MsTeams: map[string]interface{}{"width": "Full"},
})
}

// AppendItem appends an item, such as text or an image, to the Adaptive Card.
func (c *AdaptiveCard) AppendItem(i AdaptiveCardItem) {
c.Body = append(c.Body, i)
}

// AdaptiveCardItem is an interface for adaptive card items such as containers, elements and inputs.
type AdaptiveCardItem interface {
MarshalJSON() ([]byte, error)
}

// AdaptiveCardTextBlockItem is a TextBlock.
type AdaptiveCardTextBlockItem struct {
Color string
Size string
Text string
Weight string
Wrap bool
}

func (i AdaptiveCardTextBlockItem) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Text string `json:"text"`
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Wrap bool `json:"wrap,omitempty"`
}{
Type: "TextBlock",
Text: i.Text,
Color: i.Color,
Size: i.Size,
Weight: i.Weight,
Wrap: i.Wrap,
})
}

// AdaptiveCardActionSetItem is an ActionSet.
type AdaptiveCardActionSetItem struct {
Actions []AdaptiveCardActionItem
}

func (i AdaptiveCardActionSetItem) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Actions []AdaptiveCardActionItem `json:"actions"`
}{
Type: "ActionSet",
Actions: i.Actions,
})
}

type AdaptiveCardActionItem interface {
MarshalJSON() ([]byte, error)
}

// AdaptiveCardOpenURLActionItem is an Action.OpenUrl action.
type AdaptiveCardOpenURLActionItem struct {
IconURL string
Title string
URL string
}

func (i AdaptiveCardOpenURLActionItem) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url"`
IconURL string `json:"iconUrl,omitempty"`
}{
Type: "Action.OpenUrl",
Title: i.Title,
URL: i.URL,
IconURL: i.IconURL,
})
}
128 changes: 128 additions & 0 deletions notify/msteams/adaptive_cards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright 2024 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package msteams

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestNewAdaptiveCard(t *testing.T) {
card := NewAdaptiveCard()

require.NotNil(t, card)
require.Equal(t, "http://adaptivecards.io/schemas/adaptive-card.json", card.Schema)
require.Equal(t, "AdaptiveCard", card.Type)
require.Equal(t, "1.4", card.Version)
require.Empty(t, card.Body)
}

func TestAdaptiveCard_MarshalJSON(t *testing.T) {
card := NewAdaptiveCard()
card.AppendItem(AdaptiveCardTextBlockItem{Text: "Text"})

bytes, err := card.MarshalJSON()
require.NoError(t, err)

expectedJSON := `
{
"body":[
{"type":"TextBlock","text":"Text"}
],
"msTeams":{"width":"Full"},
"$schema":"http://adaptivecards.io/schemas/adaptive-card.json",
"type":"AdaptiveCard",
"version":"1.4"
}`
require.JSONEq(t, expectedJSON, string(bytes))
}

func TestNewAdaptiveCardsMessage(t *testing.T) {
card := NewAdaptiveCard()
message := NewAdaptiveCardsMessage(card)

require.Equal(t, "message", message.Type)
require.Len(t, message.Attachments, 1)
require.Equal(t, "application/vnd.microsoft.card.adaptive", message.Attachments[0].ContentType)
require.Equal(t, card, message.Attachments[0].Content)
}

func TestAdaptiveCardTextBlockItem_MarshalJSON(t *testing.T) {
item := AdaptiveCardTextBlockItem{
Text: "hello world",
Color: "test-color",
Size: "medium",
Weight: "bold",
Wrap: true,
}

bytes, err := item.MarshalJSON()
require.NoError(t, err)

expectedJSON := `{
"type": "TextBlock",
"text": "hello world",
"color": "test-color",
"size": "medium",
"weight": "bold",
"wrap": true
}`
require.JSONEq(t, expectedJSON, string(bytes))
}

func AdaptiveCardActionSetItemMarshalJSON(t *testing.T) {
item := AdaptiveCardActionSetItem{
Actions: []AdaptiveCardActionItem{
AdaptiveCardOpenURLActionItem{
Title: "View URL",
URL: "https://example.com",
},
},
}

bytes, err := item.MarshalJSON()
require.NoError(t, err)

expectedJSON := `{
"type":"ActionSet",
"actions":[
{
"type":"Action.OpenUrl",
"title":"View URL",
"url":"https://example.com"
}
]
}`
require.JSONEq(t, expectedJSON, string(bytes))
}

func AdaptiveCardOpenURLActionItemMarshalJSON(t *testing.T) {
item := AdaptiveCardOpenURLActionItem{
IconURL: "https://example.com/icon.png",
Title: "View URL",
URL: "https://example.com",
}

bytes, err := item.MarshalJSON()
require.NoError(t, err)

expectedJSON := `{
"type":"Action.OpenUrl",
"title":"View URL",
"url":"https://example.com",
"iconUrl":"https://example.com/icon.png"
}`
require.JSONEq(t, expectedJSON, string(bytes))
}
70 changes: 39 additions & 31 deletions notify/msteams/msteams.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ import (
)

const (
colorRed = "8C1A1A"
colorGreen = "2DC72D"
colorGrey = "808080"
TextColorAttention = "attention"
TextColorGood = "good"

TextSizeLarge = "large"

TextWeightBolder = "bolder"
)

type Notifier struct {
Expand All @@ -50,16 +53,6 @@ type Notifier struct {
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}

// Message card reference can be found at https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference.
type teamsMessage struct {
Context string `json:"@context"`
Type string `json:"type"`
Title string `json:"title"`
Summary string `json:"summary"`
Text string `json:"text"`
ThemeColor string `json:"themeColor"`
}

// New returns a new notifier that uses the Microsoft Teams Webhook API.
func New(c *config.MSTeamsConfig, t *template.Template, l log.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "msteams", httpOpts...)
Expand Down Expand Up @@ -107,14 +100,30 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return false, err
}

alerts := types.Alerts(as...)
color := colorGrey
switch alerts.Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
card := NewAdaptiveCard()
card.AppendItem(AdaptiveCardTextBlockItem{
Color: getTeamsTextColor(types.Alerts(as...)),
Text: title,
Size: TextSizeLarge,
Weight: TextWeightBolder,
Wrap: true,
})
card.AppendItem(AdaptiveCardTextBlockItem{
Text: text,
Wrap: true,
})

card.AppendItem(AdaptiveCardActionSetItem{
Actions: []AdaptiveCardActionItem{
AdaptiveCardOpenURLActionItem{
Title: "View URL",
URL: n.tmpl.ExternalURL.String(),
},
},
})

msg := NewAdaptiveCardsMessage(card)
msg.Summary = summary

var url string
if n.conf.WebhookURL != nil {
Expand All @@ -127,17 +136,8 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
url = strings.TrimSpace(string(content))
}

t := teamsMessage{
Context: "http://schema.org/extensions",
Type: "MessageCard",
Title: title,
Summary: summary,
Text: text,
ThemeColor: color,
}

var payload bytes.Buffer
if err = json.NewEncoder(&payload).Encode(t); err != nil {
if err = json.NewEncoder(&payload).Encode(msg); err != nil {
return false, err
}

Expand All @@ -154,3 +154,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
}
return shouldRetry, err
}

// getTeamsTextColor returns the text color for the message title.
func getTeamsTextColor(alerts model.Alerts) string {
if alerts.Status() == model.AlertFiring {
return TextColorAttention
}
return TextColorGood
}
Loading

0 comments on commit 60521fa

Please sign in to comment.