From c0cc85b5f150fb6df5b6f262bcd48a215109f271 Mon Sep 17 00:00:00 2001 From: aimuz Date: Wed, 19 Oct 2022 12:17:37 +0800 Subject: [PATCH] Alerting: Add support for wecom apiapp (#55991) This change adds new functionality to the wecom alerting contact point. In addition to a webhook address, you can now send alerts to the wecom apiapp endpoint. Based on https://github.com/grafana/grafana/discussions/55883 Signed-off-by: aimuz --- .../ngalert/notifier/channels/wecom.go | 170 +++++++- .../ngalert/notifier/channels/wecom_test.go | 386 +++++++++++++++++- .../channels_config/available_channels.go | 60 ++- 3 files changed, 594 insertions(+), 22 deletions(-) diff --git a/pkg/services/ngalert/notifier/channels/wecom.go b/pkg/services/ngalert/notifier/channels/wecom.go index 44fbb725bc9..bf4f229b0a8 100644 --- a/pkg/services/ngalert/notifier/channels/wecom.go +++ b/pkg/services/ngalert/notifier/channels/wecom.go @@ -5,40 +5,92 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "time" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + "golang.org/x/sync/singleflight" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/notifications" ) +var weComEndpoint = "https://qyapi.weixin.qq.com" + +const defaultWeComChannelType = "groupRobot" +const defaultWeComMsgType = WeComMsgTypeMarkdown +const defaultWeComToUser = "@all" + +type WeComMsgType string + +const WeComMsgTypeMarkdown WeComMsgType = "markdown" // use these in available_channels.go too +const WeComMsgTypeText WeComMsgType = "text" + +// IsValid checks wecom message type +func (mt WeComMsgType) IsValid() bool { + return mt == WeComMsgTypeMarkdown || mt == WeComMsgTypeText +} + type wecomSettings struct { - URL string `json:"url" yaml:"url"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` + channel string + EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` + URL string `json:"url" yaml:"url"` + AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` + CorpID string `json:"corp_id,omitempty" yaml:"corp_id,omitempty"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + MsgType WeComMsgType `json:"msgtype,omitempty" yaml:"msgtype,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + ToUser string `json:"touser,omitempty" yaml:"touser,omitempty"` } func buildWecomSettings(factoryConfig FactoryConfig) (wecomSettings, error) { - var settings = wecomSettings{} + var settings = wecomSettings{ + channel: defaultWeComChannelType, + } err := factoryConfig.Config.unmarshalSettings(&settings) if err != nil { return settings, fmt.Errorf("failed to unmarshal settings: %w", err) } - if settings.Message == "" { + if len(settings.EndpointURL) == 0 { + settings.EndpointURL = weComEndpoint + } + + if !settings.MsgType.IsValid() { + settings.MsgType = defaultWeComMsgType + } + + if len(settings.Message) == 0 { settings.Message = DefaultMessageEmbed } - if settings.Title == "" { + if len(settings.Title) == 0 { settings.Title = DefaultMessageTitleEmbed } + if len(settings.ToUser) == 0 { + settings.ToUser = defaultWeComToUser + } settings.URL = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL) - if settings.URL == "" { - return settings, errors.New("could not find webhook URL in settings") + settings.Secret = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "secret", settings.Secret) + + if len(settings.URL) == 0 && len(settings.Secret) == 0 { + return settings, errors.New("either url or secret is required") } + + if len(settings.URL) == 0 { + settings.channel = "apiapp" + if len(settings.AgentID) == 0 { + return settings, errors.New("could not find AgentID in settings") + } + if len(settings.CorpID) == 0 { + return settings, errors.New("could not find CorpID in settings") + } + } + return settings, nil } @@ -76,10 +128,13 @@ func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) { // WeComNotifier is responsible for sending alert notifications to WeCom. type WeComNotifier struct { *Base - tmpl *template.Template - log log.Logger - ns notifications.WebhookSender - settings wecomSettings + tmpl *template.Template + log log.Logger + ns notifications.WebhookSender + settings wecomSettings + tok *WeComAccessToken + tokExpireAt time.Time + group singleflight.Group } // Notify send an alert notification to WeCom. @@ -90,28 +145,50 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e tmpl, _ := TmplText(ctx, w.tmpl, as, w.log, &tmplErr) bodyMsg := map[string]interface{}{ - "msgtype": "markdown", + "msgtype": w.settings.MsgType, } content := fmt.Sprintf("# %s\n%s\n", tmpl(w.settings.Title), tmpl(w.settings.Message), ) + if w.settings.MsgType != defaultWeComMsgType { + content = fmt.Sprintf("%s\n%s\n", + tmpl(w.settings.Title), + tmpl(w.settings.Message), + ) + } - bodyMsg["markdown"] = map[string]interface{}{ + msgType := string(w.settings.MsgType) + bodyMsg[msgType] = map[string]interface{}{ "content": content, } + url := w.settings.URL + if w.settings.channel != defaultWeComChannelType { + bodyMsg["agentid"] = w.settings.AgentID + bodyMsg["touser"] = w.settings.ToUser + token, err := w.GetAccessToken(ctx) + if err != nil { + return false, err + } + url = fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/message/send?access_token=%s", token) + } + body, err := json.Marshal(bodyMsg) if err != nil { return false, err } + if tmplErr != nil { + w.log.Warn("failed to template WeCom message", "err", tmplErr.Error()) + } + cmd := &models.SendWebhookSync{ - Url: w.settings.URL, + Url: url, Body: string(body), } - if err := w.ns.SendWebhookSync(ctx, cmd); err != nil { + if err = w.ns.SendWebhookSync(ctx, cmd); err != nil { w.log.Error("failed to send WeCom webhook", "err", err, "notification", w.Name) return false, err } @@ -119,6 +196,67 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e return true, nil } +// GetAccessToken returns the access token for apiapp +func (w *WeComNotifier) GetAccessToken(ctx context.Context) (string, error) { + t := w.tok + if w.tokExpireAt.Before(time.Now()) || w.tok == nil { + // avoid multiple calls when there are multiple alarms + tok, err, _ := w.group.Do("GetAccessToken", func() (interface{}, error) { + return w.getAccessToken(ctx) + }) + if err != nil { + return "", err + } + t = tok.(*WeComAccessToken) + // expire five minutes in advance to avoid using it when it is about to expire + w.tokExpireAt = time.Now().Add(time.Second * time.Duration(t.ExpireIn-300)) + w.tok = t + } + return t.AccessToken, nil +} + +type WeComAccessToken struct { + AccessToken string `json:"access_token"` + ErrMsg string `json:"errmsg"` + ErrCode int `json:"errcode"` + ExpireIn int `json:"expire_in"` +} + +func (w *WeComNotifier) getAccessToken(ctx context.Context) (*WeComAccessToken, error) { + geTokenURL := fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/gettoken?corpid=%s&corpsecret=%s", w.settings.CorpID, w.settings.Secret) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, geTokenURL, nil) + if err != nil { + return nil, err + } + + request.Header.Add("Content-Type", "application/json") + request.Header.Add("User-Agent", "Grafana") + + resp, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("WeCom returned statuscode invalid status code: %v", resp.Status) + } + defer func() { + _ = resp.Body.Close() + }() + + var accessToken WeComAccessToken + err = json.NewDecoder(resp.Body).Decode(&accessToken) + if err != nil { + return nil, err + } + + if accessToken.ErrCode != 0 { + return nil, fmt.Errorf("WeCom returned errmsg: %s", accessToken.ErrMsg) + } + return &accessToken, nil +} + func (w *WeComNotifier) SendResolved() bool { return !w.GetDisableResolveMessage() } diff --git a/pkg/services/ngalert/notifier/channels/wecom_test.go b/pkg/services/ngalert/notifier/channels/wecom_test.go index dd497fb781a..1239e4b7e34 100644 --- a/pkg/services/ngalert/notifier/channels/wecom_test.go +++ b/pkg/services/ngalert/notifier/channels/wecom_test.go @@ -3,8 +3,13 @@ package channels import ( "context" "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "net/url" "testing" + "time" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -12,6 +17,7 @@ import ( "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" @@ -50,7 +56,8 @@ func TestWeComNotifier(t *testing.T) { "msgtype": "markdown", }, expMsgError: nil, - }, { + }, + { name: "Custom config with multiple alerts", settings: `{ "url": "http://localhost", @@ -76,7 +83,8 @@ func TestWeComNotifier(t *testing.T) { "msgtype": "markdown", }, expMsgError: nil, - }, { + }, + { name: "Custom title and message with multiple alerts", settings: `{ "url": "http://localhost", @@ -103,10 +111,11 @@ func TestWeComNotifier(t *testing.T) { "msgtype": "markdown", }, expMsgError: nil, - }, { + }, + { name: "Error in initing", settings: `{}`, - expInitError: `could not find webhook URL in settings`, + expInitError: `either url or secret is required`, }, { name: "Use default if optional fields are explicitly empty", @@ -127,6 +136,25 @@ func TestWeComNotifier(t *testing.T) { }, expMsgError: nil, }, + { + name: "Use text are explicitly empty", + settings: `{"url": "http://localhost", "message": "", "title": "", "msgtype": "text"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "text": map[string]interface{}{ + "content": "[FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", + }, + "msgtype": "text", + }, + expMsgError: nil, + }, } for _, c := range cases { @@ -178,3 +206,353 @@ func TestWeComNotifier(t *testing.T) { }) } } + +// TestWeComNotifierAPIAPP Testing API Channels +func TestWeComNotifierAPIAPP(t *testing.T) { + tmpl := templateForTests(t) + + externalURL, err := url.Parse("http://localhost") + require.NoError(t, err) + tmpl.ExternalURL = externalURL + + tests := []struct { + name string + settings string + statusCode int + accessToken string + alerts []*types.Alert + expMsg map[string]interface{} + expInitError string + expMsgError error + }{ + { + name: "not AgentID", + settings: `{"secret": "secret"}`, + accessToken: "access_token", + expInitError: "could not find AgentID in settings", + }, + { + name: "not CorpID", + settings: `{"secret": "secret", "agent_id": "agent_id"}`, + accessToken: "access_token", + expInitError: "could not find CorpID in settings", + }, + { + name: "Default APIAPP config with one alert", + settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, + accessToken: "access_token", + expInitError: "", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "markdown": map[string]interface{}{ + "content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", + }, + "msgtype": "markdown", + "agentid": "agent_id", + "touser": "@all", + }, + }, + { + name: "Custom message(markdown) with multiple alert", + settings: `{ + "secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", + "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} + `, + accessToken: "access_token", + expInitError: "", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "markdown": map[string]interface{}{ + "content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n", + }, + "msgtype": "markdown", + "agentid": "agent_id", + "touser": "@all", + }, + expMsgError: nil, + }, + { + name: "Custom message(Text) with multiple alert", + settings: `{ + "secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", + "msgtype": "text", + "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} + `, + accessToken: "access_token", + expInitError: "", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "text": map[string]interface{}{ + "content": "[FIRING:2] \n2 alerts are firing, 0 are resolved\n", + }, + "msgtype": "text", + "agentid": "agent_id", + "touser": "@all", + }, + expMsgError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.URL.Query().Get("access_token") + if accessToken != tt.accessToken { + t.Errorf("Expected access_token=%s got %s", tt.accessToken, accessToken) + return + } + + expBody, err := json.Marshal(tt.expMsg) + require.NoError(t, err) + + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, string(expBody), string(b)) + })) + defer server.Close() + + settingsJSON, err := simplejson.NewJson([]byte(tt.settings)) + require.NoError(t, err) + + m := &NotificationChannelConfig{ + Name: "wecom_testing", + Type: "wecom", + Settings: settingsJSON, + } + + webhookSender := mockNotificationService() + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + + fc := FactoryConfig{ + Config: m, + NotificationService: webhookSender, + DecryptFunc: secretsService.GetDecryptedValue, + ImageStore: nil, + Template: tmpl, + } + + pn, err := buildWecomNotifier(fc) + if tt.expInitError != "" { + require.Equal(t, tt.expInitError, err.Error()) + return + } + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + // Avoid calling GetAccessToken interfaces + pn.tokExpireAt = time.Now().Add(10 * time.Second) + pn.tok = &WeComAccessToken{AccessToken: tt.accessToken} + + ok, err := pn.Notify(ctx, tt.alerts...) + if tt.expMsgError != nil { + require.False(t, ok) + require.Error(t, err) + require.Equal(t, tt.expMsgError.Error(), err.Error()) + return + } + require.NoError(t, err) + require.True(t, ok) + + expBody, err := json.Marshal(tt.expMsg) + require.NoError(t, err) + + require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) + }) + } +} + +func TestWeComNotifier_GetAccessToken(t *testing.T) { + type fields struct { + tok *WeComAccessToken + tokExpireAt time.Time + corpid string + secret string + } + tests := []struct { + name string + fields fields + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "no corpid", + fields: fields{ + tok: nil, + tokExpireAt: time.Now().Add(-time.Minute), + }, + want: "", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.Error(t, err, i...) + }, + }, + { + name: "no corpsecret", + fields: fields{ + tok: nil, + tokExpireAt: time.Now().Add(-time.Minute), + }, + want: "", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.Error(t, err, i...) + }, + }, + { + name: "get access token", + fields: fields{ + corpid: "corpid", + secret: "secret", + }, + want: "access_token", + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + corpid := r.URL.Query().Get("corpid") + corpsecret := r.URL.Query().Get("corpsecret") + + assert.Equal(t, corpid, tt.fields.corpid, fmt.Sprintf("Expected corpid=%s got %s", tt.fields.corpid, corpid)) + if len(corpid) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + assert.Equal(t, corpsecret, tt.fields.secret, fmt.Sprintf("Expected corpsecret=%s got %s", tt.fields.secret, corpsecret)) + if len(corpsecret) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + b, err := json.Marshal(map[string]interface{}{ + "errcode": 0, + "errmsg": "ok", + "access_token": tt.want, + "expires_in": 7200, + }) + assert.NoError(t, err) + w.WriteHeader(http.StatusOK) + _, err = w.Write(b) + assert.NoError(t, err) + })) + defer server.Close() + + w := &WeComNotifier{ + settings: wecomSettings{ + EndpointURL: server.URL, + CorpID: tt.fields.corpid, + Secret: tt.fields.secret, + }, + tok: tt.fields.tok, + tokExpireAt: tt.fields.tokExpireAt, + } + got, err := w.GetAccessToken(context.Background()) + if !tt.wantErr(t, err, "GetAccessToken()") { + return + } + assert.Equalf(t, tt.want, got, "GetAccessToken()") + }) + } +} + +func TestWeComFactory(t *testing.T) { + tests := []struct { + name string + settings string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "null", + settings: "{}", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.Contains(t, err.Error(), "either url or secret is required", i...) + }, + }, + { + name: "webhook url", + settings: `{"url": "https://example.com"}`, + wantErr: assert.NoError, + }, + { + name: "apiapp missing AgentID", + settings: `{"secret": "secret"}`, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.Contains(t, err.Error(), "could not find AgentID in settings", i...) + }, + }, + { + name: "apiapp missing CorpID", + settings: `{"secret": "secret", "agent_id": "agent_id"}`, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.Contains(t, err.Error(), "could not find CorpID in settings", i...) + }, + }, + { + name: "apiapp", + settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settingsJSON, err := simplejson.NewJson([]byte(tt.settings)) + require.NoError(t, err) + + m := &NotificationChannelConfig{ + Name: "wecom_testing", + Type: "wecom", + Settings: settingsJSON, + } + + webhookSender := mockNotificationService() + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + + fc := FactoryConfig{ + Config: m, + NotificationService: webhookSender, + DecryptFunc: secretsService.GetDecryptedValue, + ImageStore: nil, + } + + _, err = WeComFactory(fc) + if !tt.wantErr(t, err, fmt.Sprintf("WeComFactory(%v)", fc)) { + return + } + }) + } +} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 0b98633cff1..ebbacceef64 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -696,13 +696,62 @@ func GetAvailableNotifiers() []*NotifierPlugin { Heading: "WeCom settings", Options: []NotifierOption{ { - Label: "URL", + Label: "Webhook URL", + Description: "Required if using GroupRobot", Element: ElementTypeInput, InputType: InputTypeText, Placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx", PropertyName: "url", - Required: true, Secure: true, + Required: true, + DependsOn: "secret", + }, + { + Label: "Agent ID", + Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#apps create ApiApp", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "1000002", + PropertyName: "agent_id", + Required: true, + DependsOn: "url", + }, + { + Label: "Corp ID", + Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#profile", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "wwxxxxxxxxx", + PropertyName: "corp_id", + Required: true, + DependsOn: "url", + }, + { + Label: "Secret", + Description: "Required if using APIAPP", + Element: ElementTypeInput, + InputType: InputTypePassword, + Placeholder: "secret", + PropertyName: "secret", + Secure: true, + Required: true, + DependsOn: "url", + }, + { + Label: "Message Type", + Element: ElementTypeSelect, + PropertyName: "msgtype", + SelectOptions: []SelectOption{ + { + Value: "text", + Label: "Text", + }, + { + Value: "markdown", + Label: "Markdown", + }, + }, + Placeholder: "Text", }, { Label: "Message", @@ -719,6 +768,13 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "title", Placeholder: `{{ template "default.title" . }}`, }, + { + Label: "To User", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "@all", + PropertyName: "touser", + }, }, }, {