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
2 changes: 2 additions & 0 deletions app/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/target/goalert/app/lifecycle"
"github.com/target/goalert/expflag"
"github.com/target/goalert/notification/email"
"github.com/target/goalert/notification/teams"
"github.com/target/goalert/notification/webhook"
"github.com/target/goalert/retry"

Expand Down Expand Up @@ -95,6 +96,7 @@ func (app *App) startup(ctx context.Context) error {
app.DestRegistry.RegisterProvider(ctx, app.slackChan.DMSender())
app.DestRegistry.RegisterProvider(ctx, app.slackChan.UserGroupSender())
app.DestRegistry.RegisterProvider(ctx, webhook.NewSender(ctx, app.httpClient))
app.DestRegistry.RegisterProvider(ctx, teams.NewSender(ctx, app.httpClient))
if app.cfg.StubNotifiers {
app.DestRegistry.StubNotifiers()
}
Expand Down
23 changes: 23 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ type Config struct {
DisableBroadcastThreadReplies bool `info:"Disable broadcasting alert status updates in threads to the main channel." public:"true"`
}

Teams struct {
Enable bool `public:"true" info:"Enables sending notifications to Microsoft Teams channels via Power Automate Workflow webhooks."`
AllowedWorkflowURLs []string `public:"true" info:"If set, allows Teams workflow webhook URLs for these domains only."`
}

Twilio struct {
Enable bool `public:"true" info:"Enables sending and processing of Voice and SMS messages through the Twilio notification provider."`

Expand Down Expand Up @@ -343,6 +348,24 @@ func (cfg Config) ValidWebhookURL(testURL string) bool {
return false
}

// ValidTeamsWorkflowURL returns true if the URL is an allowed Microsoft Teams
// workflow webhook target.
func (cfg Config) ValidTeamsWorkflowURL(testURL string) bool {
if len(cfg.Teams.AllowedWorkflowURLs) == 0 {
return true
}
for _, baseU := range cfg.Teams.AllowedWorkflowURLs {
matched, err := MatchURL(baseU, testURL)
if err != nil {
return false
}
if matched {
return true
}
}
return false
}

// ShouldUsePublicURL returns true if redirects, validation, etc.. should use the
// configured PublicURL instead of host/referer.
func (cfg Config) ShouldUsePublicURL() bool { return cfg.explicitURL != "" }
Expand Down
12 changes: 12 additions & 0 deletions graphql2/mapconfig.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

195 changes: 195 additions & 0 deletions notification/teams/card.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package teams

import (
"context"
"fmt"
"strings"

"github.com/target/goalert/config"
"github.com/target/goalert/notification"
)

// Adaptive Card color values (https://adaptivecards.io/explorer/TextBlock.html).
const (
colorClosed = "Good"
colorUnacked = "Attention"
colorAcked = "Warning"
)

// CardVersion is the Adaptive Card schema version used for all outgoing
// cards. 1.2 is the safest widely-supported version in Microsoft Teams,
// including cards posted through Power Automate Workflows.
const CardVersion = "1.2"

const maxDetailsLen = 2000

// AdaptiveCard is a minimal Adaptive Card representation, limited to the
// conservative subset of the schema that Microsoft Teams supports when cards
// are posted via a Power Automate Workflow webhook.
type AdaptiveCard struct {
Type string `json:"type"`
Schema string `json:"$schema"`
Version string `json:"version"`
Body []any `json:"body"`
Actions []CardAction `json:"actions,omitempty"`
MSTeams *MSTeams `json:"msteams,omitempty"`
}

// MSTeams contains Teams-specific rendering properties.
type MSTeams struct {
Width string `json:"width,omitempty"`
}

// TextBlock is an Adaptive Card TextBlock element.
type TextBlock struct {
Type string `json:"type"`
Text string `json:"text"`
Wrap bool `json:"wrap,omitempty"`
Weight string `json:"weight,omitempty"`
Size string `json:"size,omitempty"`
Color string `json:"color,omitempty"`
IsSubtle bool `json:"isSubtle,omitempty"`
Spacing string `json:"spacing,omitempty"`
}

// FactSet is an Adaptive Card FactSet element.
type FactSet struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}

// Fact is a single title/value pair within a FactSet.
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}

// CardAction is an Adaptive Card action; only Action.OpenUrl is used since
// submit-style actions require a bot to receive them.
type CardAction struct {
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
}

func newCard(body []any, actions ...CardAction) AdaptiveCard {
return AdaptiveCard{
Type: "AdaptiveCard",
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Version: CardVersion,
Body: body,
Actions: actions,
MSTeams: &MSTeams{Width: "Full"},
}
}

func openURLAction(title, url string) CardAction {
return CardAction{Type: "Action.OpenUrl", Title: title, URL: url}
}

func title(text string) TextBlock {
return TextBlock{Type: "TextBlock", Text: text, Wrap: true, Weight: "Bolder", Size: "Medium"}
}

func body(text string) TextBlock {
return TextBlock{Type: "TextBlock", Text: text, Wrap: true}
}

func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

func stateInfo(state notification.AlertState) (color, defaultText string) {
switch state {
case notification.AlertStateAcknowledged:
return colorAcked, "Acknowledged"
case notification.AlertStateClosed:
return colorClosed, "Closed"
default:
return colorUnacked, "Unacknowledged"
}
}

// alertCard renders an alert notification or status update as an Adaptive
// Card, colored by the current alert state (matching Slack's color scheme).
func alertCard(ctx context.Context, alertID int, summary, details, serviceName, logEntry string, state notification.AlertState) AdaptiveCard {
cfg := config.FromContext(ctx)

color, defaultText := stateInfo(state)
if logEntry == "" {
logEntry = defaultText
}

elements := []any{
title(fmt.Sprintf("Alert #%d: %s", alertID, summary)),
TextBlock{Type: "TextBlock", Text: logEntry, Wrap: true, Color: color, Weight: "Bolder"},
}

var facts []Fact
if serviceName != "" {
facts = append(facts, Fact{Title: "Service", Value: serviceName})
}
if len(facts) > 0 {
elements = append(elements, FactSet{Type: "FactSet", Facts: facts})
}

if details != "" {
elements = append(elements, TextBlock{
Type: "TextBlock", Text: truncate(details, maxDetailsLen),
Wrap: true, IsSubtle: true, Spacing: "Medium",
})
}

return newCard(elements,
openURLAction("Open Alert", cfg.CallbackURL(fmt.Sprintf("/alerts/%d", alertID))),
)
}

// alertBundleCard renders a bundled-alerts notification for a service.
func alertBundleCard(ctx context.Context, serviceID, serviceName string, count int) AdaptiveCard {
cfg := config.FromContext(ctx)

return newCard([]any{
title(fmt.Sprintf("Service '%s' has %d unacknowledged alerts.", serviceName, count)),
},
openURLAction("Open Alerts", cfg.CallbackURL("/services/"+serviceID+"/alerts")),
)
}

// onCallCard renders a schedule on-call notification.
func onCallCard(_ context.Context, m notification.ScheduleOnCallUsers) AdaptiveCard {
var text string
if len(m.Users) == 0 {
text = fmt.Sprintf("No one is on-call for %s.", m.ScheduleName)
} else {
names := make([]string, len(m.Users))
for i, u := range m.Users {
names[i] = fmt.Sprintf("[%s](%s)", u.Name, u.URL)
}
text = fmt.Sprintf("On-call for %s: %s", m.ScheduleName, strings.Join(names, ", "))
}

return newCard([]any{
title(fmt.Sprintf("On-Call Notification: %s", m.ScheduleName)),
body(text),
},
openURLAction("Open Schedule", m.ScheduleURL),
)
}

// testCard renders a test notification.
func testCard(ctx context.Context) AdaptiveCard {
cfg := config.FromContext(ctx)
return newCard([]any{
title(fmt.Sprintf("%s Test Notification", cfg.ApplicationName())),
body("This is a test message."),
})
}

// signalCard renders a dynamic-action signal message.
func signalCard(_ context.Context, message string) AdaptiveCard {
return newCard([]any{body(message)})
}
98 changes: 98 additions & 0 deletions notification/teams/card_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package teams

import (
"context"
"encoding/json"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/target/goalert/config"
"github.com/target/goalert/notification"
)

func testCtx() context.Context {
var cfg config.Config
cfg.General.PublicURL = "http://example.com"
cfg.Teams.Enable = true
return cfg.Context(context.Background())
}

func toJSON(t *testing.T, v any) string {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
return string(data)
}

func TestAlertCard(t *testing.T) {
ctx := testCtx()

card := alertCard(ctx, 123, "Example Summary", "Example Details", "Example Service", "", notification.AlertStateUnacknowledged)
doc := toJSON(t, card)

assert.Contains(t, doc, `"Alert #123: Example Summary"`)
assert.Contains(t, doc, `"Unacknowledged"`)
assert.Contains(t, doc, `"color":"Attention"`)
assert.Contains(t, doc, `"Example Details"`)
assert.Contains(t, doc, `"Example Service"`)
assert.Contains(t, doc, `"url":"http://example.com/alerts/123"`)
assert.Contains(t, doc, `"version":"1.2"`)

card = alertCard(ctx, 123, "Example Summary", "", "", "Acknowledged by Joe", notification.AlertStateAcknowledged)
doc = toJSON(t, card)
assert.Contains(t, doc, `"Acknowledged by Joe"`)
assert.Contains(t, doc, `"color":"Warning"`)

card = alertCard(ctx, 123, "Example Summary", "", "", "", notification.AlertStateClosed)
doc = toJSON(t, card)
assert.Contains(t, doc, `"Closed"`)
assert.Contains(t, doc, `"color":"Good"`)
}

func TestAlertCard_TruncatesDetails(t *testing.T) {
long := strings.Repeat("x", maxDetailsLen+100)
card := alertCard(testCtx(), 1, "s", long, "", "", notification.AlertStateUnacknowledged)
doc := toJSON(t, card)

assert.NotContains(t, doc, long)
assert.Contains(t, doc, strings.Repeat("x", maxDetailsLen)+"…")
}

func TestAlertBundleCard(t *testing.T) {
card := alertBundleCard(testCtx(), "svc-id", "Example Service", 6)
doc := toJSON(t, card)

assert.Contains(t, doc, `"Service 'Example Service' has 6 unacknowledged alerts."`)
assert.Contains(t, doc, `"url":"http://example.com/services/svc-id/alerts"`)
}

func TestOnCallCard(t *testing.T) {
card := onCallCard(testCtx(), notification.ScheduleOnCallUsers{
ScheduleName: "Example Schedule",
ScheduleURL: "http://example.com/schedules/sched-id",
Users: []notification.User{
{Name: "Alice", URL: "http://example.com/users/alice"},
{Name: "Bob", URL: "http://example.com/users/bob"},
},
})
doc := toJSON(t, card)

assert.Contains(t, doc, "[Alice](http://example.com/users/alice)")
assert.Contains(t, doc, "[Bob](http://example.com/users/bob)")
assert.Contains(t, doc, `"url":"http://example.com/schedules/sched-id"`)

card = onCallCard(testCtx(), notification.ScheduleOnCallUsers{
ScheduleName: "Example Schedule",
ScheduleURL: "http://example.com/schedules/sched-id",
})
doc = toJSON(t, card)
assert.Contains(t, doc, "No one is on-call")
}

func TestTestCard(t *testing.T) {
doc := toJSON(t, testCard(testCtx()))
assert.Contains(t, doc, "GoAlert Test Notification")
assert.Contains(t, doc, "This is a test message.")
}
Loading
Loading