diff --git a/CHANGELOG.md b/CHANGELOG.md index db497019e..a0bbee881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Alerting -- +- [FEATURE] Add an opt-in Telegram setting to send a single screenshot and short message as a photo with caption. ## Scope Glossary @@ -43,4 +43,3 @@ Scopes must have an order to ensure consistency and ease of search, this helps u 3. `[BUGFIX]` 4. `[ENHANCEMENT]` 5. `[ADMIN]` - diff --git a/receivers/telegram/v1/config.go b/receivers/telegram/v1/config.go index ec9d2028c..583a67ced 100644 --- a/receivers/telegram/v1/config.go +++ b/receivers/telegram/v1/config.go @@ -29,6 +29,7 @@ type Config struct { DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty" yaml:"disable_web_page_preview,omitempty"` ProtectContent bool `json:"protect_content,omitempty" yaml:"protect_content,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty" yaml:"disable_notifications,omitempty"` + SendMessageAsCaption bool `json:"send_message_as_caption,omitempty" yaml:"send_message_as_caption,omitempty"` } func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Config, error) { @@ -41,6 +42,7 @@ func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Confi DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty" yaml:"disable_web_page_preview,omitempty"` ProtectContent bool `json:"protect_content,omitempty" yaml:"protect_content,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty" yaml:"disable_notifications,omitempty"` + SendMessageAsCaption bool `json:"send_message_as_caption,omitempty" yaml:"send_message_as_caption,omitempty"` }{} if err := json.Unmarshal(jsonData, &raw); err != nil { return Config{}, fmt.Errorf("failed to unmarshal settings: %w", err) @@ -52,6 +54,7 @@ func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Confi DisableWebPagePreview: raw.DisableWebPagePreview, ProtectContent: raw.ProtectContent, DisableNotifications: raw.DisableNotifications, + SendMessageAsCaption: raw.SendMessageAsCaption, } settings.BotToken = decryptFn.Get("bottoken", raw.BotToken) @@ -191,5 +194,11 @@ var Schema = schema.NewIntegrationSchemaVersion(schema.IntegrationSchemaVersion{ Element: schema.ElementTypeCheckbox, PropertyName: "disable_notifications", }, + { + Label: "Send Message as Image Caption", + Description: "Sends a notification with one screenshot and a short message as a single Telegram photo with caption. Longer messages and notifications with multiple screenshots are sent separately.", + Element: schema.ElementTypeCheckbox, + PropertyName: "send_message_as_caption", + }, }, }) diff --git a/receivers/telegram/v1/config_test.go b/receivers/telegram/v1/config_test.go index 77db21c33..52fd1a815 100644 --- a/receivers/telegram/v1/config_test.go +++ b/receivers/telegram/v1/config_test.go @@ -129,6 +129,7 @@ func TestNewConfig(t *testing.T) { DisableWebPagePreview: true, ProtectContent: true, DisableNotifications: true, + SendMessageAsCaption: true, }, }, { @@ -144,6 +145,7 @@ func TestNewConfig(t *testing.T) { DisableWebPagePreview: true, ProtectContent: true, DisableNotifications: true, + SendMessageAsCaption: true, }, }, { diff --git a/receivers/telegram/v1/telegram.go b/receivers/telegram/v1/telegram.go index 5709c8788..c0ec18711 100644 --- a/receivers/telegram/v1/telegram.go +++ b/receivers/telegram/v1/telegram.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "mime/multipart" + "unicode/utf8" "github.com/go-kit/log/level" "github.com/prometheus/alertmanager/notify" @@ -24,6 +25,7 @@ var ( // Telegram supports 4096 chars max - from https://limits.tginfo.me/en. const telegramMaxMessageLenRunes = 4096 +const telegramMaxCaptionLenRunes = 1024 // Notifier is responsible for sending // alert notifications to Telegram. @@ -52,6 +54,16 @@ func New(cfg Config, meta receivers.Metadata, template *templates.Template, send // Notify send an alert notification to Telegram. func (tn *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { l := tn.GetLogger(ctx) + + if tn.settings.SendMessageAsCaption { + sent, err := tn.notifyWithCaption(ctx, l, as) + if err != nil { + _ = level.Warn(l).Log("msg", "failed to send Telegram image with caption, falling back to separate messages", "error", err) + } else if sent { + return true, nil + } + } + // Create the cmd for sendMessage cmd, err := tn.newWebhookSyncCmd("sendMessage", func(w *multipart.Writer) error { msg, err := tn.buildTelegramMessage(ctx, as, l) @@ -79,18 +91,7 @@ func (tn *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error return nil } cmd, err = tn.newWebhookSyncCmd("sendPhoto", func(w *multipart.Writer) error { - f, err := image.RawData(ctx) - if err != nil { - return fmt.Errorf("failed to open image: %w", err) - } - fw, err := w.CreateFormFile("photo", f.Name) - if err != nil { - return fmt.Errorf("failed to create form file: %w", err) - } - if _, err := fw.Write(f.Content); err != nil { - return fmt.Errorf("failed to write to form file: %w", err) - } - return nil + return tn.writeTelegramImage(ctx, w, image) }) if err != nil { return fmt.Errorf("failed to create image: %w", err) @@ -105,6 +106,75 @@ func (tn *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error return true, nil } +func (tn *Notifier) notifyWithCaption(ctx context.Context, l log.Logger, as []*types.Alert) (bool, error) { + msg, err := tn.buildTelegramMessage(ctx, as, l) + if err != nil { + return false, fmt.Errorf("failed to build message: %w", err) + } + if utf8.RuneCountInString(msg["text"]) > telegramMaxCaptionLenRunes { + return false, nil + } + + storedImages := make([]images.Image, 0, 1) + uploadedImages := make(map[string]struct{}) + _ = images.WithStoredImages(ctx, l, tn.images, func(_ int, image images.Image) error { + if _, ok := uploadedImages[image.ID]; ok && image.ID != "" { + return nil + } + uploadedImages[image.ID] = struct{}{} + storedImages = append(storedImages, image) + if len(storedImages) > 1 { + return images.ErrImagesDone + } + return nil + }, as...) + if len(storedImages) != 1 { + return false, nil + } + + cmd, err := tn.newWebhookSyncCmd("sendPhoto", func(w *multipart.Writer) error { + if err := tn.writeTelegramImage(ctx, w, storedImages[0]); err != nil { + return err + } + if err := w.WriteField("caption", msg["text"]); err != nil { + return fmt.Errorf("failed to create caption field: %w", err) + } + if parseMode := msg["parse_mode"]; parseMode != "" { + if err := w.WriteField("parse_mode", parseMode); err != nil { + return fmt.Errorf("failed to create parse mode field: %w", err) + } + } + if protectContent := msg["protect_content"]; protectContent != "" { + if err := w.WriteField("protect_content", protectContent); err != nil { + return fmt.Errorf("failed to create protect content field: %w", err) + } + } + return nil + }) + if err != nil { + return false, fmt.Errorf("failed to create telegram image with caption: %w", err) + } + if err := tn.ns.SendWebhook(ctx, l, cmd); err != nil { + return false, fmt.Errorf("failed to send telegram image with caption: %w", err) + } + return true, nil +} + +func (tn *Notifier) writeTelegramImage(ctx context.Context, w *multipart.Writer, image images.Image) error { + f, err := image.RawData(ctx) + if err != nil { + return fmt.Errorf("failed to open image: %w", err) + } + fw, err := w.CreateFormFile("photo", f.Name) + if err != nil { + return fmt.Errorf("failed to create form file: %w", err) + } + if _, err := fw.Write(f.Content); err != nil { + return fmt.Errorf("failed to write to form file: %w", err) + } + return nil +} + func (tn *Notifier) buildTelegramMessage(ctx context.Context, as []*types.Alert, l log.Logger) (map[string]string, error) { var tmplErr error defer func() { diff --git a/receivers/telegram/v1/telegram_test.go b/receivers/telegram/v1/telegram_test.go index 67d294bf7..c6b09b55f 100644 --- a/receivers/telegram/v1/telegram_test.go +++ b/receivers/telegram/v1/telegram_test.go @@ -260,6 +260,224 @@ Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=aler } } +func TestNotify_SendMessageAsCaption(t *testing.T) { + tmpl := templates.ForTests(t) + provider := images.NewFakeProviderWithFile(t, 1) + notificationService := receivers.MockNotificationService() + n := &Notifier{ + Base: receivers.NewBase(receivers.Metadata{}, log.NewNopLogger()), + ns: notificationService, + tmpl: tmpl, + images: provider, + settings: Config{ + BotToken: "abcdefgh0123456789", + ChatID: "someid", + MessageThreadID: "threadid", + Message: "Alert with screenshot", + ParseMode: "HTML", + ProtectContent: true, + DisableNotifications: true, + SendMessageAsCaption: true, + }, + } + alerts := []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, + }, + }} + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + recoverable, err := n.Notify(ctx, alerts...) + require.NoError(t, err) + assert.True(t, recoverable) + require.Len(t, notificationService.WebhookCalls, 1) + + call := notificationService.WebhookCalls[0] + assert.Equal(t, "https://api.telegram.org/bot"+n.settings.BotToken+"/sendPhoto", call.URL) + mediaType, params, err := mime.ParseMediaType(call.HTTPHeader["Content-Type"]) + require.NoError(t, err) + require.Equal(t, "multipart/form-data", mediaType) + + reader := multipart.NewReader(strings.NewReader(call.Body), params["boundary"]) + data := map[string]string{} + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + content, err := io.ReadAll(part) + require.NoError(t, err) + if part.FileName() != "" { + data[part.FormName()] = part.FileName() + } else { + data[part.FormName()] = string(content) + } + } + assert.Equal(t, map[string]string{ + "caption": "Alert with screenshot", + "chat_id": "someid", + "disable_notification": "true", + "message_thread_id": "threadid", + "parse_mode": "HTML", + "photo": "test-image-1.jpg", + "protect_content": "true", + }, data) +} + +func TestNotify_SendMessageAsCaptionFallsBack(t *testing.T) { + tmpl := templates.ForTests(t) + provider := images.NewFakeProviderWithFile(t, 2) + cases := []struct { + name string + message string + alerts []*types.Alert + calls int + }{ + { + name: "message exceeds caption limit", + message: strings.Repeat("x", telegramMaxCaptionLenRunes+1), + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, + }, + }}, + calls: 2, + }, + { + name: "notification has multiple images", + message: "Multiple screenshots", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert2"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-2"}, + }, + }, + }, + calls: 3, + }, + { + name: "notification has no image", + message: "No screenshot", + alerts: []*types.Alert{{ + Alert: model.Alert{Labels: model.LabelSet{"alertname": "alert1"}}, + }}, + calls: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + notificationService := receivers.MockNotificationService() + n := &Notifier{ + Base: receivers.NewBase(receivers.Metadata{}, log.NewNopLogger()), + ns: notificationService, + tmpl: tmpl, + images: provider, + settings: Config{ + BotToken: "abcdefgh0123456789", + ChatID: "someid", + Message: tc.message, + ParseMode: "HTML", + SendMessageAsCaption: true, + }, + } + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + recoverable, err := n.Notify(ctx, tc.alerts...) + require.NoError(t, err) + assert.True(t, recoverable) + require.Len(t, notificationService.WebhookCalls, tc.calls) + assert.Equal(t, "https://api.telegram.org/bot"+n.settings.BotToken+"/sendMessage", notificationService.WebhookCalls[0].URL) + }) + } +} + +func TestNotify_SendMessageAsCaptionFallsBackOnError(t *testing.T) { + t.Run("when image data cannot be read", func(t *testing.T) { + notificationService := receivers.MockNotificationService() + notifier := &Notifier{ + Base: receivers.NewBase(receivers.Metadata{}, log.NewNopLogger()), + settings: Config{ + BotToken: "token", + ChatID: "1234", + Message: "Alert with screenshot", + SendMessageAsCaption: true, + }, + images: images.NewFakeProvider(1), + ns: notificationService, + tmpl: templates.ForTests(t), + } + + alerts := []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, + }, + }} + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + recoverable, err := notifier.Notify(ctx, alerts...) + + require.NoError(t, err) + require.True(t, recoverable) + require.Len(t, notificationService.WebhookCalls, 1) + require.Equal(t, "https://api.telegram.org/bottoken/sendMessage", notificationService.WebhookCalls[0].URL) + }) + + t.Run("when sendPhoto fails", func(t *testing.T) { + sender := receivers.NewMockWebhookSender() + sender.SendWebhookFunc = func(_ context.Context, cmd *receivers.SendWebhookSettings) error { + if strings.HasSuffix(cmd.URL, "/sendPhoto") { + return errors.New("sendPhoto failed") + } + return nil + } + notifier := &Notifier{ + Base: receivers.NewBase(receivers.Metadata{}, log.NewNopLogger()), + settings: Config{ + BotToken: "token", + ChatID: "1234", + Message: "Alert with screenshot", + SendMessageAsCaption: true, + }, + images: images.NewFakeProviderWithFile(t, 1), + ns: sender, + tmpl: templates.ForTests(t), + } + + alerts := []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1"}, + Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, + }, + }} + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + recoverable, err := notifier.Notify(ctx, alerts...) + + require.NoError(t, err) + require.True(t, recoverable) + require.Len(t, sender.Calls, 3) + require.Equal(t, "https://api.telegram.org/bottoken/sendPhoto", sender.Calls[0].Args[2].(*receivers.SendWebhookSettings).URL) + require.Equal(t, "https://api.telegram.org/bottoken/sendMessage", sender.Calls[1].Args[2].(*receivers.SendWebhookSettings).URL) + require.Equal(t, "https://api.telegram.org/bottoken/sendPhoto", sender.Calls[2].Args[2].(*receivers.SendWebhookSettings).URL) + }) +} + func TestNotify_ExtraData(t *testing.T) { tmpl := templates.ForTests(t) diff --git a/receivers/telegram/v1/testing.go b/receivers/telegram/v1/testing.go index b65c02ee6..08b8bd1ba 100644 --- a/receivers/telegram/v1/testing.go +++ b/receivers/telegram/v1/testing.go @@ -9,7 +9,8 @@ const FullValidConfigForTesting = `{ "parse_mode" :"html", "disable_web_page_preview" :true, "protect_content" :true, - "disable_notifications" :true + "disable_notifications" :true, + "send_message_as_caption" :true }` // FullValidSecretsForTesting is a string representation of JSON object that contains all fields that can be overridden from secrets