From d105265095f68f6adda4dbc03ffe27c2a435b05b Mon Sep 17 00:00:00 2001 From: Ameya Pingulkar Date: Wed, 20 May 2026 13:48:59 -0400 Subject: [PATCH] Add Custom Webhook notification destination Signed-off-by: Ameya Pingulkar --- app/startup.go | 3 + config/config.go | 8 + graphql2/mapconfig.go | 16 ++ notification/webhook/customsender.go | 201 ++++++++++++++++++ notification/webhook/nfydest.go | 111 +++++++++- web/src/app/documentation/Documentation.tsx | 8 +- .../app/selection/DestinationInputDirect.tsx | 3 + web/src/app/storybook/defaultDestTypes.ts | 50 +++++ web/src/schema.d.ts | 2 + 9 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 notification/webhook/customsender.go diff --git a/app/startup.go b/app/startup.go index 724718dfae..67ae2f5c8c 100644 --- a/app/startup.go +++ b/app/startup.go @@ -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/googlechat" "github.com/target/goalert/notification/webhook" "github.com/target/goalert/retry" @@ -95,6 +96,8 @@ 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, &webhook.CustomSender{Client: app.httpClient}) + app.DestRegistry.RegisterProvider(ctx, googlechat.NewSender(ctx, app.httpClient)) if app.cfg.StubNotifiers { app.DestRegistry.StubNotifiers() } diff --git a/config/config.go b/config/config.go index 5bed42d45e..7e7ca99fa4 100644 --- a/config/config.go +++ b/config/config.go @@ -141,6 +141,14 @@ type Config struct { AllowedURLs []string `public:"true" info:"If set, allows webhooks for these domains only."` } + GoogleChat struct { + Enable bool `public:"true" info:"Enables Google Chat as a notification destination."` + } + + CustomWebhook struct { + Enable bool `public:"true" info:"Enables Custom Webhook as a notification destination."` + } + Feedback struct { Enable bool `public:"true" info:"Enables Feedback link in nav bar."` OverrideURL string `public:"true" info:"Use a custom URL for Feedback link in nav bar."` diff --git a/graphql2/mapconfig.go b/graphql2/mapconfig.go index f53c8bd316..dac3ae5373 100644 --- a/graphql2/mapconfig.go +++ b/graphql2/mapconfig.go @@ -89,6 +89,8 @@ func MapConfigValues(cfg config.Config) []ConfigValue { {ID: "SMTP.Password", Type: ConfigTypeString, Description: "Password for authentication.", Value: cfg.SMTP.Password, Password: true}, {ID: "Webhook.Enable", Type: ConfigTypeBoolean, Description: "Enables webhook as a contact method.", Value: fmt.Sprintf("%t", cfg.Webhook.Enable)}, {ID: "Webhook.AllowedURLs", Type: ConfigTypeStringList, Description: "If set, allows webhooks for these domains only.", Value: strings.Join(cfg.Webhook.AllowedURLs, "\n")}, + {ID: "GoogleChat.Enable", Type: ConfigTypeBoolean, Description: "Enables Google Chat as a notification destination.", Value: fmt.Sprintf("%t", cfg.GoogleChat.Enable)}, + {ID: "CustomWebhook.Enable", Type: ConfigTypeBoolean, Description: "Enables Custom Webhook as a notification destination.", Value: fmt.Sprintf("%t", cfg.CustomWebhook.Enable)}, {ID: "Feedback.Enable", Type: ConfigTypeBoolean, Description: "Enables Feedback link in nav bar.", Value: fmt.Sprintf("%t", cfg.Feedback.Enable)}, {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, } @@ -124,6 +126,8 @@ func MapPublicConfigValues(cfg config.Config) []ConfigValue { {ID: "SMTP.From", Type: ConfigTypeString, Description: "The email address messages should be sent from.", Value: cfg.SMTP.From}, {ID: "Webhook.Enable", Type: ConfigTypeBoolean, Description: "Enables webhook as a contact method.", Value: fmt.Sprintf("%t", cfg.Webhook.Enable)}, {ID: "Webhook.AllowedURLs", Type: ConfigTypeStringList, Description: "If set, allows webhooks for these domains only.", Value: strings.Join(cfg.Webhook.AllowedURLs, "\n")}, + {ID: "GoogleChat.Enable", Type: ConfigTypeBoolean, Description: "Enables Google Chat as a notification destination.", Value: fmt.Sprintf("%t", cfg.GoogleChat.Enable)}, + {ID: "CustomWebhook.Enable", Type: ConfigTypeBoolean, Description: "Enables Custom Webhook as a notification destination.", Value: fmt.Sprintf("%t", cfg.CustomWebhook.Enable)}, {ID: "Feedback.Enable", Type: ConfigTypeBoolean, Description: "Enables Feedback link in nav bar.", Value: fmt.Sprintf("%t", cfg.Feedback.Enable)}, {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, } @@ -383,6 +387,18 @@ func ApplyConfigValues(cfg config.Config, vals []ConfigValueInput) (config.Confi cfg.Webhook.Enable = val case "Webhook.AllowedURLs": cfg.Webhook.AllowedURLs = parseStringList(v.Value) + case "GoogleChat.Enable": + val, err := parseBool(v.ID, v.Value) + if err != nil { + return cfg, err + } + cfg.GoogleChat.Enable = val + case "CustomWebhook.Enable": + val, err := parseBool(v.ID, v.Value) + if err != nil { + return cfg, err + } + cfg.CustomWebhook.Enable = val case "Feedback.Enable": val, err := parseBool(v.ID, v.Value) if err != nil { diff --git a/notification/webhook/customsender.go b/notification/webhook/customsender.go new file mode 100644 index 0000000000..2e4fb28fc3 --- /dev/null +++ b/notification/webhook/customsender.go @@ -0,0 +1,201 @@ +package webhook + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "net/http" + "strings" + "text/template" + "time" + + "github.com/target/goalert/config" + "github.com/target/goalert/notification" + "github.com/target/goalert/validation" +) + +type CustomSender struct { + Client *http.Client +} + +type customWebhookPayload struct { + MessageID string + AppName string + Type string + + AlertID int + Summary string + Details string + ServiceID string + ServiceName string + Count int + LogEntry string + Code string + ScheduleID string + ScheduleName string + ScheduleURL string + Users []notification.User + Meta map[string]string + NewAlertState string +} + +func (s *CustomSender) client() *http.Client { + if s != nil && s.Client != nil { + return s.Client + } + return http.DefaultClient +} + +func (s *CustomSender) SendMessage(ctx context.Context, msg notification.Message) (*notification.SentMessage, error) { + cfg := config.FromContext(ctx) + + webURL := msg.DestArg(FieldWebhookURL) + if !cfg.ValidWebhookURL(webURL) { + return ¬ification.SentMessage{ + State: notification.StateFailedPerm, + StateDetails: "invalid or not allowed URL", + }, nil + } + + tpl, err := template.New("body").Option("missingkey=error").Parse(msg.DestArg(FieldBodyTemplate)) + if err != nil { + return ¬ification.SentMessage{ + State: notification.StateFailedPerm, + StateDetails: err.Error(), + }, nil + } + + data := renderCustomWebhookPayload(cfg, msg) + var body bytes.Buffer + if err := tpl.Execute(&body, data); err != nil { + return ¬ification.SentMessage{ + State: notification.StateFailedPerm, + StateDetails: err.Error(), + }, nil + } + + contentType := msg.DestArg(FieldContentType) + if contentType == "" { + contentType = "application/json" + } + if _, _, err := mime.ParseMediaType(contentType); err != nil { + return ¬ification.SentMessage{ + State: notification.StateFailedPerm, + StateDetails: err.Error(), + }, nil + } + + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webURL, bytes.NewReader(body.Bytes())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + + resp, err := s.client().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return ¬ification.SentMessage{State: notification.StateSent}, nil + } + + respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if readErr != nil { + return nil, readErr + } + + details := strings.TrimSpace(string(respBody)) + if details != "" { + details = fmt.Sprintf("%s: %s", resp.Status, details) + } else { + details = resp.Status + } + + state := notification.StateFailedPerm + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + state = notification.StateFailedTemp + } + + return ¬ification.SentMessage{ + State: state, + StateDetails: details, + }, nil +} + +func renderCustomWebhookPayload(cfg config.Config, msg notification.Message) customWebhookPayload { + res := customWebhookPayload{ + MessageID: msg.MsgID(), + AppName: cfg.ApplicationName(), + Type: fmt.Sprintf("%T", msg), + } + + switch t := msg.(type) { + case notification.Test: + res.Type = "Test" + case notification.Verification: + res.Type = "Verification" + res.Code = t.Code + case notification.Alert: + res.Type = "Alert" + res.AlertID = t.AlertID + res.Summary = t.Summary + res.Details = t.Details + res.ServiceID = t.ServiceID + res.ServiceName = t.ServiceName + res.Meta = t.Meta + case notification.AlertBundle: + res.Type = "AlertBundle" + res.ServiceID = t.ServiceID + res.ServiceName = t.ServiceName + res.Count = t.Count + case notification.AlertStatus: + res.Type = "AlertStatus" + res.AlertID = t.AlertID + res.LogEntry = t.LogEntry + res.ServiceID = t.ServiceID + res.Summary = t.Summary + res.Details = t.Details + res.NewAlertState = alertStateText(t.NewAlertState) + case notification.ScheduleOnCallUsers: + res.Type = "ScheduleOnCallUsers" + res.ScheduleID = t.ScheduleID + res.ScheduleName = t.ScheduleName + res.ScheduleURL = t.ScheduleURL + res.Users = append([]notification.User(nil), t.Users...) + default: + res.Type = fmt.Sprintf("%T", msg) + } + + return res +} + +func alertStateText(state notification.AlertState) string { + switch state { + case notification.AlertStateUnacknowledged: + return "unacknowledged" + case notification.AlertStateAcknowledged: + return "acknowledged" + case notification.AlertStateClosed: + return "closed" + default: + return "" + } +} + +func parseTemplate(body string) (*template.Template, error) { + if body == "" { + return nil, validation.NewFieldError(FieldBodyTemplate, "required") + } + tpl, err := template.New("body").Option("missingkey=error").Parse(body) + if err != nil { + return nil, validation.WrapError(err) + } + return tpl, nil +} diff --git a/notification/webhook/nfydest.go b/notification/webhook/nfydest.go index 18c24d41c1..0d2f9506dc 100644 --- a/notification/webhook/nfydest.go +++ b/notification/webhook/nfydest.go @@ -2,6 +2,7 @@ package webhook import ( "context" + "mime" "net/url" "github.com/target/goalert/config" @@ -12,21 +13,36 @@ import ( ) const ( - DestTypeWebhook = "builtin-webhook" - FieldWebhookURL = "webhook_url" - ParamBody = "body" - ParamContentType = "content_type" - FallbackIconURL = "builtin://webhook" + DestTypeWebhook = "builtin-webhook" + DestTypeCustomWebhook = "builtin-custom-webhook" + FieldWebhookURL = "webhook_url" + FieldBodyTemplate = "body_template" + FieldContentType = "content_type" + ParamBody = "body" + ParamContentType = "content_type" + FallbackIconURL = "builtin://webhook" ) func NewWebhookDest(url string) gadb.DestV1 { return gadb.NewDestV1(DestTypeWebhook, FieldWebhookURL, url) } +func NewCustomWebhookDest(url, bodyTemplate, contentType string) gadb.DestV1 { + return gadb.NewDestV1( + DestTypeCustomWebhook, + FieldWebhookURL, url, + FieldBodyTemplate, bodyTemplate, + FieldContentType, contentType, + ) +} + var _ (nfydest.Provider) = (*Sender)(nil) +var _ (nfydest.Provider) = (*CustomSender)(nil) func (Sender) ID() string { return DestTypeWebhook } +func (CustomSender) ID() string { return DestTypeCustomWebhook } + func (Sender) TypeInfo(ctx context.Context) (*nfydest.TypeInfo, error) { cfg := config.FromContext(ctx) return &nfydest.TypeInfo{ @@ -64,6 +80,41 @@ func (Sender) TypeInfo(ctx context.Context) (*nfydest.TypeInfo, error) { }, nil } +func (CustomSender) TypeInfo(ctx context.Context) (*nfydest.TypeInfo, error) { + cfg := config.FromContext(ctx) + return &nfydest.TypeInfo{ + Type: DestTypeCustomWebhook, + Name: "Custom Webhook", + Enabled: cfg.CustomWebhook.Enable, + SupportsUserVerification: true, + SupportsOnCallNotify: true, + SupportsStatusUpdates: true, + SupportsAlertNotifications: true, + StatusUpdatesRequired: true, + RequiredFields: []nfydest.FieldConfig{{ + FieldID: FieldWebhookURL, + Label: "Webhook URL", + PlaceholderText: "https://example.com", + InputType: "url", + Hint: "Webhook Documentation", + HintURL: "/docs#webhooks", + SupportsValidation: true, + }, { + FieldID: FieldBodyTemplate, + Label: "Body Template", + PlaceholderText: "{\"text\":\"{{.Summary}}\"}", + InputType: "text", + Hint: "Go template used as the request body. All notification fields are available.", + }, { + FieldID: FieldContentType, + Label: "Content Type", + PlaceholderText: "application/json", + InputType: "text", + Hint: "Optional. Defaults to application/json when left blank.", + }}, + }, nil +} + func (s *Sender) ValidateField(ctx context.Context, fieldID, value string) error { cfg := config.FromContext(ctx) switch fieldID { @@ -82,6 +133,40 @@ func (s *Sender) ValidateField(ctx context.Context, fieldID, value string) error return validation.NewGenericError("unknown field ID") } +func (s *CustomSender) ValidateField(ctx context.Context, fieldID, value string) error { + cfg := config.FromContext(ctx) + switch fieldID { + case FieldWebhookURL: + err := validate.AbsoluteURL(FieldWebhookURL, value) + if err != nil { + return err + } + if !cfg.ValidWebhookURL(value) { + return validation.NewGenericError("url is not allowed by administrator") + } + return nil + case FieldBodyTemplate: + if value == "" { + return validation.NewFieldError(FieldBodyTemplate, "required") + } + _, err := parseTemplate(value) + if err != nil { + return err + } + return nil + case FieldContentType: + if value == "" { + return nil + } + if _, _, err := mime.ParseMediaType(value); err != nil { + return validation.WrapError(err) + } + return nil + } + + return validation.NewGenericError("unknown field ID") +} + func (s *Sender) DisplayInfo(ctx context.Context, args map[string]string) (*nfydest.DisplayInfo, error) { if args == nil { args = make(map[string]string) @@ -97,3 +182,19 @@ func (s *Sender) DisplayInfo(ctx context.Context, args map[string]string) (*nfyd Text: u.Hostname(), }, nil } + +func (s *CustomSender) DisplayInfo(ctx context.Context, args map[string]string) (*nfydest.DisplayInfo, error) { + if args == nil { + args = make(map[string]string) + } + + u, err := url.Parse(args[FieldWebhookURL]) + if err != nil { + return nil, validation.WrapError(err) + } + return &nfydest.DisplayInfo{ + IconURL: FallbackIconURL, + IconAltText: "Custom Webhook", + Text: u.Hostname(), + }, nil +} diff --git a/web/src/app/documentation/Documentation.tsx b/web/src/app/documentation/Documentation.tsx index 6c6706d1ac..4077d2b29e 100644 --- a/web/src/app/documentation/Documentation.tsx +++ b/web/src/app/documentation/Documentation.tsx @@ -16,15 +16,17 @@ const useStyles = makeStyles({ }) export default function Documentation(): React.JSX.Element { - const [publicURL, webhookEnabled] = useConfigValue( + const [publicURL, webhookEnabled, googleChatEnabled, customWebhookEnabled] = useConfigValue( 'General.PublicURL', 'Webhook.Enable', + 'GoogleChat.Enable', + 'CustomWebhook.Enable', ) const classes = useStyles() // NOTE list markdown documents here let markdownDocs = [{ doc: integrationKeys, id: 'integration-keys' }] - if (webhookEnabled) { + if (webhookEnabled || googleChatEnabled || customWebhookEnabled) { markdownDocs.push({ doc: webhooks, id: 'webhooks' }) } @@ -44,7 +46,7 @@ export default function Documentation(): React.JSX.Element { if (!el) return el.scrollIntoView() - }, [webhookEnabled, publicURL]) + }, [webhookEnabled, googleChatEnabled, customWebhookEnabled, publicURL]) return ( diff --git a/web/src/app/selection/DestinationInputDirect.tsx b/web/src/app/selection/DestinationInputDirect.tsx index a62ec5c9ac..9bc7332b16 100644 --- a/web/src/app/selection/DestinationInputDirect.tsx +++ b/web/src/app/selection/DestinationInputDirect.tsx @@ -86,6 +86,7 @@ export default function DestinationInputDirect( } let iprops: Partial = {} + const multiline = props.fieldID === 'body_template' if (props.prefix) { iprops.startAdornment = ( @@ -132,6 +133,8 @@ export default function DestinationInputDirect( hintURL={props.hintURL} /> } + multiline={multiline} + rows={multiline ? 8 : undefined} onChange={handleChange} value={trimPrefix(props.value, props.prefix || '')} error={!!props.error} diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts index 4fee38ccbc..6d28531f73 100644 --- a/web/src/app/storybook/defaultDestTypes.ts +++ b/web/src/app/storybook/defaultDestTypes.ts @@ -171,4 +171,54 @@ export const destTypes: DestinationTypeInfo[] = [ }, ], }, + { + type: 'builtin-custom-webhook', + name: 'Custom Webhook', + enabled: true, + userDisclaimer: 'Custom webhook payloads use Go templates.', + isContactMethod: true, + isEPTarget: true, + isSchedOnCallNotify: true, + isDynamicAction: false, + iconURL: 'builtin://webhook', + iconAltText: 'Custom Webhook', + supportsStatusUpdates: true, + statusUpdatesRequired: true, + dynamicParams: [], + requiredFields: [ + { + fieldID: 'webhook_url', + label: 'Webhook URL', + hint: 'Webhook Documentation', + hintURL: '/docs#webhooks', + placeholderText: 'https://example.com', + prefix: '', + inputType: 'url', + supportsSearch: false, + supportsValidation: true, + }, + { + fieldID: 'body_template', + label: 'Body Template', + hint: 'Go template used as the request body. All notification fields are available.', + hintURL: '', + placeholderText: '{"text":"{{.Summary}}"}', + prefix: '', + inputType: 'text', + supportsSearch: false, + supportsValidation: false, + }, + { + fieldID: 'content_type', + label: 'Content Type', + hint: 'Optional. Defaults to application/json when left blank.', + hintURL: '', + placeholderText: 'application/json', + prefix: '', + inputType: 'text', + supportsSearch: false, + supportsValidation: false, + }, + ], + }, ] diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 9699d529d7..f0647ece43 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -1678,5 +1678,7 @@ type ConfigID = | 'SMTP.Password' | 'Webhook.Enable' | 'Webhook.AllowedURLs' + | 'GoogleChat.Enable' + | 'CustomWebhook.Enable' | 'Feedback.Enable' | 'Feedback.OverrideURL'