diff --git a/api/controller/events.go b/api/controller/events.go index c38fd9152..f982273c5 100644 --- a/api/controller/events.go +++ b/api/controller/events.go @@ -8,7 +8,12 @@ import ( "github.com/moira-alert/moira/api/dto" ) -// GetTriggerEvents gets trigger event from current page and all trigger event count. Events list is filtered by time range +const ( + zeroPage int64 = 0 + allEventsSize int64 = -1 +) + +// GetTriggerEvents gets trigger events from current page and total count of filtered trigger events. Events list is filtered by time range // with `from` and `to` params (`from` and `to` should be "+inf", "-inf" or int64 converted to string), // by metric (regular expression) and by states. If `states` map is empty or nil then all states are accepted. func GetTriggerEvents( @@ -19,11 +24,36 @@ func GetTriggerEvents( metricRegexp *regexp.Regexp, states map[string]struct{}, ) (*dto.EventsList, *api.ErrorResponse) { - events, err := getFilteredNotificationEvents(database, triggerID, page, size, from, to, metricRegexp, states) + events, err := getFilteredNotificationEvents(database, triggerID, from, to, metricRegexp, states) if err != nil { return nil, api.ErrorInternalServer(err) } - eventCount := database.GetNotificationEventCount(triggerID, -1) + + eventCount := int64(len(events)) + + if page < 0 || (page > 0 && size < 0) { + return &dto.EventsList{ + Size: size, + Page: page, + Total: eventCount, + List: []moira.NotificationEvent{}, + }, nil + } + + if page >= 0 && size >= 0 { + start := page * size + end := start + size + + if start >= eventCount { + events = []*moira.NotificationEvent{} + } else { + if end > eventCount { + end = eventCount + } + + events = events[start:end] + } + } eventsList := &dto.EventsList{ Size: size, @@ -42,44 +72,16 @@ func GetTriggerEvents( func getFilteredNotificationEvents( database moira.Database, triggerID string, - page, size int64, from, to string, metricRegexp *regexp.Regexp, states map[string]struct{}, ) ([]*moira.NotificationEvent, error) { - // fetch all events - if size < 0 { - events, err := database.GetNotificationEvents(triggerID, page, size, from, to) - if err != nil { - return nil, err - } - - return filterNotificationEvents(events, metricRegexp, states), nil - } - - // fetch at most `size` events - filtered := make([]*moira.NotificationEvent, 0, size) - var count int64 - - for int64(len(filtered)) < size { - eventsData, err := database.GetNotificationEvents(triggerID, page+count, size, from, to) - if err != nil { - return nil, err - } - - if len(eventsData) == 0 { - break - } - - filtered = append(filtered, filterNotificationEvents(eventsData, metricRegexp, states)...) - count += 1 - - if int64(len(eventsData)) < size { - break - } + events, err := database.GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to) + if err != nil { + return nil, err } - return filtered, nil + return filterNotificationEvents(events, metricRegexp, states), nil } func filterNotificationEvents( diff --git a/api/controller/events_test.go b/api/controller/events_test.go index 4c84f7fc2..bd1a0c18a 100644 --- a/api/controller/events_test.go +++ b/api/controller/events_test.go @@ -25,31 +25,41 @@ func TestGetEvents(t *testing.T) { defer mockCtrl.Finish() triggerID := uuid.Must(uuid.NewV4()).String() - var page int64 = 10 - var size int64 = 100 + var page int64 = 1 + var size int64 = 2 from := "-inf" to := "+inf" Convey("Test has events", t, func() { - var total int64 = 6000000 - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to). - Return([]*moira.NotificationEvent{ - { - State: moira.StateNODATA, - OldState: moira.StateOK, - }, - { - State: moira.StateOK, - OldState: moira.StateNODATA, - }, - }, nil) - dataBase.EXPECT().GetNotificationEventCount(triggerID, int64(-1)).Return(total) + events := []*moira.NotificationEvent{ + { + State: moira.StateNODATA, + OldState: moira.StateOK, + }, + { + State: moira.StateOK, + OldState: moira.StateNODATA, + }, + { + State: moira.StateWARN, + OldState: moira.StateOK, + }, + { + State: moira.StateERROR, + OldState: moira.StateWARN, + }, + } + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to). + Return(events, nil) list, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, allMetrics, allStates) So(err, ShouldBeNil) So(list, ShouldResemble, &dto.EventsList{ - List: []moira.NotificationEvent{{State: moira.StateNODATA, OldState: moira.StateOK}, {State: moira.StateOK, OldState: moira.StateNODATA}}, - Total: total, + List: []moira.NotificationEvent{ + *events[2], + *events[3], + }, + Total: int64(len(events)), Size: size, Page: page, }) @@ -57,8 +67,7 @@ func TestGetEvents(t *testing.T) { Convey("Test no events", t, func() { var total int64 - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to).Return(make([]*moira.NotificationEvent, 0), nil) - dataBase.EXPECT().GetNotificationEventCount(triggerID, int64(-1)).Return(total) + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(make([]*moira.NotificationEvent, 0), nil) list, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, allMetrics, allStates) So(err, ShouldBeNil) So(list, ShouldResemble, &dto.EventsList{ @@ -71,7 +80,7 @@ func TestGetEvents(t *testing.T) { Convey("Test error", t, func() { expected := fmt.Errorf("oooops! Can not get all contacts") - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to).Return(nil, expected) + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(nil, expected) list, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, allMetrics, allStates) So(err, ShouldResemble, api.ErrorInternalServer(expected)) So(list, ShouldBeNil) @@ -85,19 +94,23 @@ func TestGetEvents(t *testing.T) { filtered := []*moira.NotificationEvent{ {Metric: "metric.test.event1"}, {Metric: "a.metric.test.event2"}, + {Metric: "metric.test.event.other"}, } notFiltered := []*moira.NotificationEvent{ {Metric: "another.mEtric.test.event"}, {Metric: "metric.test"}, } - firstPortion := append(make([]*moira.NotificationEvent, 0), notFiltered[0], filtered[0]) - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to).Return(firstPortion, nil) - secondPortion := append(make([]*moira.NotificationEvent, 0), filtered[1], notFiltered[1]) - dataBase.EXPECT().GetNotificationEvents(triggerID, page+1, size, from, to).Return(secondPortion, nil) + events := []*moira.NotificationEvent{ + notFiltered[0], + filtered[0], + notFiltered[1], + filtered[1], + filtered[2], + } + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(events, nil) - total := int64(len(firstPortion) + len(secondPortion)) - dataBase.EXPECT().GetNotificationEventCount(triggerID, int64(-1)).Return(total) + total := int64(len(filtered)) actual, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, regexp.MustCompile(`metric\.test\.event`), allStates) So(err, ShouldBeNil) @@ -105,7 +118,7 @@ func TestGetEvents(t *testing.T) { Page: page, Size: size, Total: total, - List: toDTOList(filtered), + List: toDTOList(filtered[:size]), }) }) }) @@ -125,8 +138,7 @@ func TestGetEvents(t *testing.T) { } Convey("with empty map all allowed", func() { total := int64(len(filtered) + len(notFiltered)) - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to).Return(append(filtered, notFiltered...), nil) - dataBase.EXPECT().GetNotificationEventCount(triggerID, int64(-1)).Return(total) + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(append(filtered, notFiltered...), nil) actual, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, allMetrics, allStates) So(err, ShouldBeNil) @@ -139,9 +151,8 @@ func TestGetEvents(t *testing.T) { }) Convey("with given states", func() { - total := int64(len(filtered) + len(notFiltered)) - dataBase.EXPECT().GetNotificationEvents(triggerID, page, size, from, to).Return(append(filtered, notFiltered...), nil) - dataBase.EXPECT().GetNotificationEventCount(triggerID, int64(-1)).Return(total) + total := int64(len(filtered)) + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(append(filtered, notFiltered...), nil) actual, err := GetTriggerEvents(dataBase, triggerID, page, size, from, to, allMetrics, map[string]struct{}{ string(moira.StateOK): {}, @@ -158,6 +169,101 @@ func TestGetEvents(t *testing.T) { }) }) }) + + Convey("test paginating", t, func() { + events := []*moira.NotificationEvent{ + { + State: moira.StateNODATA, + OldState: moira.StateOK, + }, + { + State: moira.StateOK, + OldState: moira.StateNODATA, + }, + { + State: moira.StateWARN, + OldState: moira.StateOK, + }, + { + State: moira.StateERROR, + OldState: moira.StateWARN, + }, + } + total := int64(len(events)) + + type testcase struct { + description string + expectedEvents []moira.NotificationEvent + givenPage int64 + givenSize int64 + } + + testcases := []testcase{ + { + description: "with page > 0 and size > 0", + givenPage: 1, + givenSize: 1, + expectedEvents: []moira.NotificationEvent{ + *events[1], + }, + }, + { + description: "with page == 0 and size > 0", + givenPage: 0, + givenSize: 1, + expectedEvents: []moira.NotificationEvent{ + *events[0], + }, + }, + { + description: "with page > 0, size > 0, page * size + size > events count", + givenPage: 1, + givenSize: 3, + expectedEvents: []moira.NotificationEvent{ + *events[3], + }, + }, + { + description: "with page = 0, size < 0 fetch all events", + givenPage: 0, + givenSize: -10, + expectedEvents: toDTOList(events), + }, + { + description: "with page > 0, size < 0 return no events", + givenPage: 1, + givenSize: -1, + expectedEvents: []moira.NotificationEvent{}, + }, + { + description: "with page < 0 return no events", + givenPage: -1, + givenSize: 1, + expectedEvents: []moira.NotificationEvent{}, + }, + { + description: "with page * size >= len(events)", + givenPage: 1, + givenSize: int64(len(events)), + expectedEvents: []moira.NotificationEvent{}, + }, + } + + for i := range testcases { + Convey(fmt.Sprintf("test case %d: %s", i+1, testcases[i].description), func() { + dataBase.EXPECT().GetNotificationEvents(triggerID, zeroPage, allEventsSize, from, to).Return(events, nil) + + actual, err := GetTriggerEvents(dataBase, triggerID, testcases[i].givenPage, testcases[i].givenSize, from, to, allMetrics, allStates) + So(err, ShouldBeNil) + So(actual, ShouldResemble, &dto.EventsList{ + Page: testcases[i].givenPage, + Size: testcases[i].givenSize, + Total: total, + List: testcases[i].expectedEvents, + }) + }) + } + }) } func toDTOList(eventPtrs []*moira.NotificationEvent) []moira.NotificationEvent { diff --git a/api/handler/trigger_test.go b/api/handler/trigger_test.go index ad51a1840..5f099273f 100644 --- a/api/handler/trigger_test.go +++ b/api/handler/trigger_test.go @@ -428,7 +428,6 @@ func TestGetTriggerWithTriggerSource(t *testing.T) { db.EXPECT().GetTrigger(triggerId).Return(trigger, nil) db.EXPECT().GetTriggerThrottling(triggerId) db.EXPECT().GetNotificationEvents(triggerId, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(make([]*moira.NotificationEvent, 0), nil) - db.EXPECT().GetNotificationEventCount(triggerId, gomock.Any()).Return(int64(0)) responseWriter := httptest.NewRecorder() getTrigger(responseWriter, request) @@ -472,7 +471,6 @@ func TestGetTriggerWithTriggerSource(t *testing.T) { db.EXPECT().GetTrigger(triggerId).Return(trigger, nil) db.EXPECT().GetTriggerThrottling(triggerId) db.EXPECT().GetNotificationEvents(triggerId, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(make([]*moira.NotificationEvent, 0), nil) - db.EXPECT().GetNotificationEventCount(triggerId, gomock.Any()).Return(int64(0)) responseWriter := httptest.NewRecorder() getTrigger(responseWriter, request) @@ -516,7 +514,6 @@ func TestGetTriggerWithTriggerSource(t *testing.T) { db.EXPECT().GetTrigger(triggerId).Return(trigger, nil) db.EXPECT().GetTriggerThrottling(triggerId) db.EXPECT().GetNotificationEvents(triggerId, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(make([]*moira.NotificationEvent, 0), nil) - db.EXPECT().GetNotificationEventCount(triggerId, gomock.Any()).Return(int64(0)) responseWriter := httptest.NewRecorder() getTrigger(responseWriter, request) diff --git a/interfaces.go b/interfaces.go index b6e4bcd18..94fc776bd 100644 --- a/interfaces.go +++ b/interfaces.go @@ -60,7 +60,7 @@ type Database interface { DeleteTriggerThrottling(triggerID string) error // NotificationEvent storing - GetNotificationEvents(triggerID string, start, size int64, from, to string) ([]*NotificationEvent, error) + GetNotificationEvents(triggerID string, page, size int64, from, to string) ([]*NotificationEvent, error) PushNotificationEvent(event *NotificationEvent, ui bool) error GetNotificationEventCount(triggerID string, from int64) int64 FetchNotificationEvent() (NotificationEvent, error) diff --git a/senders/mattermost/sender.go b/senders/mattermost/sender.go index d83dbf6c7..36cbabb37 100644 --- a/senders/mattermost/sender.go +++ b/senders/mattermost/sender.go @@ -90,6 +90,7 @@ func (sender *Sender) Init(senderSettings interface{}, logger moira.Logger, loca location, uriFormatter, descriptionFormatter, + msgformat.DefaultDescriptionCutter, boldFormatter, eventStringFormatter, codeBlockStart, diff --git a/senders/msgformat/highlighter.go b/senders/msgformat/highlighter.go index 156609957..9a78403e4 100644 --- a/senders/msgformat/highlighter.go +++ b/senders/msgformat/highlighter.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "time" + "unicode/utf8" "github.com/moira-alert/moira" "github.com/moira-alert/moira/senders" @@ -23,8 +24,11 @@ type BoldFormatter func(str string) string // EventStringFormatter formats single event string. type EventStringFormatter func(event moira.NotificationEvent, location *time.Location) string -// HighlightSyntaxFormatter formats message by using functions, emojis and some other highlight patterns. -type HighlightSyntaxFormatter struct { +// DescriptionCutter cuts the given description to fit max size. +type DescriptionCutter func(desc string, maxSize int) string + +// highlightSyntaxFormatter formats message by using functions, emojis and some other highlight patterns. +type highlightSyntaxFormatter struct { // emojiGetter used in titles for better description. emojiGetter emoji_provider.StateEmojiGetter frontURI string @@ -32,13 +36,14 @@ type HighlightSyntaxFormatter struct { useEmoji bool uriFormatter UriFormatter descriptionFormatter DescriptionFormatter + descriptionCutter DescriptionCutter boldFormatter BoldFormatter eventsStringFormatter EventStringFormatter codeBlockStart string codeBlockEnd string } -// NewHighlightSyntaxFormatter creates new HighlightSyntaxFormatter with given arguments. +// NewHighlightSyntaxFormatter creates new highlightSyntaxFormatter with given arguments. func NewHighlightSyntaxFormatter( emojiGetter emoji_provider.StateEmojiGetter, useEmoji bool, @@ -46,18 +51,20 @@ func NewHighlightSyntaxFormatter( location *time.Location, uriFormatter UriFormatter, descriptionFormatter DescriptionFormatter, + descriptionCutter DescriptionCutter, boldFormatter BoldFormatter, eventsStringFormatter EventStringFormatter, codeBlockStart string, codeBlockEnd string, ) MessageFormatter { - return &HighlightSyntaxFormatter{ + return &highlightSyntaxFormatter{ emojiGetter: emojiGetter, frontURI: frontURI, location: location, useEmoji: useEmoji, uriFormatter: uriFormatter, descriptionFormatter: descriptionFormatter, + descriptionCutter: descriptionCutter, boldFormatter: boldFormatter, eventsStringFormatter: eventsStringFormatter, codeBlockStart: codeBlockStart, @@ -66,25 +73,25 @@ func NewHighlightSyntaxFormatter( } // Format formats message using given params and formatter functions. -func (formatter *HighlightSyntaxFormatter) Format(params MessageFormatterParams) string { +func (formatter *highlightSyntaxFormatter) Format(params MessageFormatterParams) string { var message strings.Builder state := params.Events.GetCurrentState(params.Throttled) emoji := formatter.emojiGetter.GetStateEmoji(state) title := formatter.buildTitle(params.Events, params.Trigger, emoji, params.Throttled) - titleLen := len([]rune(title)) + titleLen := utf8.RuneCountInString(title) desc := formatter.descriptionFormatter(params.Trigger) - descLen := len([]rune(desc)) + descLen := utf8.RuneCountInString(desc) eventsString := formatter.buildEventsString(params.Events, -1, params.Throttled) - eventsStringLen := len([]rune(eventsString)) + eventsStringLen := utf8.RuneCountInString(eventsString) charsLeftAfterTitle := params.MessageMaxChars - titleLen descNewLen, eventsNewLen := senders.CalculateMessagePartsLength(charsLeftAfterTitle, descLen, eventsStringLen) if descLen != descNewLen { - desc = desc[:descNewLen] + "...\n" + desc = formatter.descriptionCutter(desc, descNewLen) } if eventsNewLen != eventsStringLen { eventsString = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled) @@ -96,7 +103,7 @@ func (formatter *HighlightSyntaxFormatter) Format(params MessageFormatterParams) return message.String() } -func (formatter *HighlightSyntaxFormatter) buildTitle(events moira.NotificationEvents, trigger moira.TriggerData, emoji string, throttled bool) string { +func (formatter *highlightSyntaxFormatter) buildTitle(events moira.NotificationEvents, trigger moira.TriggerData, emoji string, throttled bool) string { state := events.GetCurrentState(throttled) title := "" if formatter.useEmoji { @@ -122,11 +129,11 @@ func (formatter *HighlightSyntaxFormatter) buildTitle(events moira.NotificationE // buildEventsString builds the string from moira events and limits it to charsForEvents. // if charsForEvents is negative buildEventsString does not limit the events string. -func (formatter *HighlightSyntaxFormatter) buildEventsString(events moira.NotificationEvents, charsForEvents int, throttled bool) string { +func (formatter *highlightSyntaxFormatter) buildEventsString(events moira.NotificationEvents, charsForEvents int, throttled bool) string { charsForThrottleMsg := 0 - throttleMsg := fmt.Sprintf("\nPlease, %s to generate less events.", formatter.boldFormatter(changeRecommendation)) + throttleMsg := fmt.Sprintf("\nPlease, %s to generate less events.", formatter.boldFormatter(ChangeTriggerRecommendation)) if throttled { - charsForThrottleMsg = len([]rune(throttleMsg)) + charsForThrottleMsg = utf8.RuneCountInString(throttleMsg) } charsLeftForEvents := charsForEvents - charsForThrottleMsg @@ -143,8 +150,8 @@ func (formatter *HighlightSyntaxFormatter) buildEventsString(events moira.Notifi } tailString = fmt.Sprintf("\n...and %d more events.", len(events)-eventsPrinted) - tailStringLen := len([]rune(formatter.codeBlockEnd)) + len("\n") + len([]rune(tailString)) - if !(charsForEvents < 0) && (len([]rune(eventsString))+len([]rune(line)) > charsLeftForEvents-tailStringLen) { + tailStringLen := utf8.RuneCountInString(formatter.codeBlockEnd) + len("\n") + utf8.RuneCountInString(tailString) + if !(charsForEvents < 0) && (utf8.RuneCountInString(eventsString)+utf8.RuneCountInString(line) > charsLeftForEvents-tailStringLen) { eventsLenLimitReached = true break } diff --git a/senders/msgformat/highlighter_test.go b/senders/msgformat/highlighter_test.go index 79bfaf85b..470db230e 100644 --- a/senders/msgformat/highlighter_test.go +++ b/senders/msgformat/highlighter_test.go @@ -30,6 +30,7 @@ func TestFormat(t *testing.T) { location, testUriFormatter, testDescriptionFormatter, + DefaultDescriptionCutter, testBoldFormatter, testEventStringFormatter, "```", @@ -135,7 +136,7 @@ func TestFormat(t *testing.T) { Convey("Long description and many events. both desc and events > msgLimit/2", func() { actual := formatter.Format(getParams(longEvents, moira.TriggerData{Desc: longDesc}, false)) expected := "**NODATA**\n" + - strings.Repeat("a", 1984) + "...\n" + + strings.Repeat("a", 1980) + "...\n" + "```\n" + strings.Repeat("02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n", 40) + "02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n" + diff --git a/senders/msgformat/msgformat.go b/senders/msgformat/msgformat.go index 5d00724b2..72a6cdb37 100644 --- a/senders/msgformat/msgformat.go +++ b/senders/msgformat/msgformat.go @@ -1,12 +1,12 @@ // Package msgformat provides MessageFormatter interface which may be used for formatting messages. -// Also, it contains some realizations such as HighlightSyntaxFormatter. +// Also, it contains some realizations such as highlightSyntaxFormatter. package msgformat import ( "github.com/moira-alert/moira" ) -const changeRecommendation = "fix your system or tune this trigger" +const ChangeTriggerRecommendation = "fix your system or tune this trigger" // MessageFormatter is used for formatting messages to send via telegram, mattermost, etc. type MessageFormatter interface { @@ -21,3 +21,10 @@ type MessageFormatterParams struct { MessageMaxChars int Throttled bool } + +// DefaultDescriptionCutter cuts description, so len(newDesc) <= maxSize. Ensure that len(desc) >= maxSize and +// maxSize >= len("...\n"). +func DefaultDescriptionCutter(desc string, maxSize int) string { + suffix := "...\n" + return desc[:maxSize-len(suffix)] + suffix +} diff --git a/senders/slack/slack.go b/senders/slack/slack.go index 54f960c90..28976c6c6 100644 --- a/senders/slack/slack.go +++ b/senders/slack/slack.go @@ -70,6 +70,7 @@ func (sender *Sender) Init(senderSettings interface{}, logger moira.Logger, loca location, uriFormatter, descriptionFormatter, + msgformat.DefaultDescriptionCutter, boldFormatter, eventStringFormatter, codeBlockStart, diff --git a/senders/slack/slack_test.go b/senders/slack/slack_test.go index ac58d2f68..43471896f 100644 --- a/senders/slack/slack_test.go +++ b/senders/slack/slack_test.go @@ -161,7 +161,7 @@ some other text italic text eventsString += eventLine } actual := sender.buildMessage(events, moira.TriggerData{Desc: longDesc}, false) - expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```" + expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```" So(actual, ShouldResemble, expected) }) @@ -174,7 +174,7 @@ some other text italic text Convey("Print moira message with both desc and events > msgLimit/2", func() { actual := sender.buildMessage(longEvents, moira.TriggerData{Desc: longDesc}, false) - expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n...and 5 more events." + expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n...and 5 more events." So(actual, ShouldResemble, expected) }) }) diff --git a/senders/telegram/init.go b/senders/telegram/init.go index 7815437e8..4659c1f69 100644 --- a/senders/telegram/init.go +++ b/senders/telegram/init.go @@ -3,7 +3,6 @@ package telegram import ( "errors" "fmt" - "html" "strings" "time" @@ -23,11 +22,6 @@ const ( hidden = "[DATA DELETED]" ) -var ( - codeBlockStart = "
" - codeBlockEnd = "
" -) - var pollerTimeout = 10 * time.Second // Structure that represents the Telegram configuration in the YAML file. @@ -78,17 +72,11 @@ func (sender *Sender) Init(senderSettings interface{}, logger moira.Logger, loca sender.apiToken = cfg.APIToken emojiProvider := telegramEmojiProvider{} - sender.formatter = msgformat.NewHighlightSyntaxFormatter( + sender.formatter = NewTelegramMessageFormatter( emojiProvider, true, cfg.FrontURI, - location, - urlFormatter, - emptyDescriptionFormatter, - boldFormatter, - eventStringFormatter, - codeBlockStart, - codeBlockEnd) + location) sender.logger = logger sender.bot, err = telebot.NewBot(telebot.Settings{ @@ -135,25 +123,3 @@ func (sender *Sender) runTelebot(contactType string) { func telegramLockKey(contactType string) string { return telegramLockPrefix + contactType } - -func urlFormatter(triggerURI, triggerName string) string { - return fmt.Sprintf("%s", triggerURI, html.EscapeString(triggerName)) -} - -func emptyDescriptionFormatter(trigger moira.TriggerData) string { - return "" -} - -func boldFormatter(str string) string { - return fmt.Sprintf("%s", html.EscapeString(str)) -} - -func eventStringFormatter(event moira.NotificationEvent, loc *time.Location) string { - return fmt.Sprintf( - "%s: %s = %s (%s to %s)", - event.FormatTimestamp(loc, moira.DefaultTimeFormat), - html.EscapeString(event.Metric), - html.EscapeString(event.GetMetricsValues(moira.DefaultNotificationSettings)), - event.OldState, - event.State) -} diff --git a/senders/telegram/message_formatter.go b/senders/telegram/message_formatter.go new file mode 100644 index 000000000..8059a2231 --- /dev/null +++ b/senders/telegram/message_formatter.go @@ -0,0 +1,256 @@ +package telegram + +import ( + "fmt" + "html" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/moira-alert/moira" + "github.com/moira-alert/moira/senders" + "github.com/moira-alert/moira/senders/emoji_provider" + "github.com/moira-alert/moira/senders/msgformat" + "github.com/russross/blackfriday/v2" +) + +const ( + eventsBlockStart = "
" + eventsBlockEnd = "
" +) + +type messageFormatter struct { + // emojiGetter used in titles for better description. + emojiGetter emoji_provider.StateEmojiGetter + frontURI string + location *time.Location + useEmoji bool +} + +// NewTelegramMessageFormatter returns message formatter which is used in telegram sender. +// The message will be formatted with html tags supported by telegram. +func NewTelegramMessageFormatter( + emojiGetter emoji_provider.StateEmojiGetter, + useEmoji bool, + frontURI string, + location *time.Location, +) msgformat.MessageFormatter { + return &messageFormatter{ + emojiGetter: emojiGetter, + frontURI: frontURI, + location: location, + useEmoji: useEmoji, + } +} + +// Format formats message using given params and formatter functions. +func (formatter *messageFormatter) Format(params msgformat.MessageFormatterParams) string { + state := params.Events.GetCurrentState(params.Throttled) + emoji := formatter.emojiGetter.GetStateEmoji(state) + + title := formatter.buildTitle(params.Events, params.Trigger, emoji, params.Throttled) + titleLen := calcRunesCountWithoutHTML([]rune(title)) + + desc := descriptionFormatter(params.Trigger) + descLen := calcRunesCountWithoutHTML([]rune(desc)) + + eventsString := formatter.buildEventsString(params.Events, -1, params.Throttled) + eventsStringLen := calcRunesCountWithoutHTML([]rune(eventsString)) + + descNewLen, eventsNewLen := senders.CalculateMessagePartsLength(params.MessageMaxChars-titleLen, descLen, eventsStringLen) + if descLen != descNewLen { + desc = descriptionCutter(desc, descNewLen) + } + if eventsStringLen != eventsNewLen { + eventsString = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled) + } + + return title + desc + eventsString +} + +// calcRunesCountWithoutHTML is used for calculating symbols in text without html tags. Special symbols +// like `>`, `<` etc. are counted not as one symbol, for example, len([]rune(">")). +// This precision is enough for us to evaluate size of message. +func calcRunesCountWithoutHTML(htmlText []rune) int { + textLen := 0 + isTag := false + + for _, r := range htmlText { + if r == '<' { + isTag = true + continue + } + + if !isTag { + textLen += 1 + } + + if r == '>' { + isTag = false + } + } + + return textLen +} + +func (formatter *messageFormatter) buildTitle(events moira.NotificationEvents, trigger moira.TriggerData, emoji string, throttled bool) string { + state := events.GetCurrentState(throttled) + title := "" + if formatter.useEmoji { + title += emoji + " " + } + + title += boldFormatter(string(state)) + triggerURI := trigger.GetTriggerURI(formatter.frontURI) + if triggerURI != "" { + title += fmt.Sprintf(" %s", uriFormatter(triggerURI, trigger.Name)) + } else if trigger.Name != "" { + title += " " + trigger.Name + } + + tags := trigger.GetTags() + if tags != "" { + title += " " + tags + } + + title += "\n" + return title +} + +var throttleMsg = fmt.Sprintf("\nPlease, %s to generate less events.", boldFormatter(msgformat.ChangeTriggerRecommendation)) + +// buildEventsString builds the string from moira events and limits it to charsForEvents. +// if charsForEvents is negative buildEventsString does not limit the events string. +func (formatter *messageFormatter) buildEventsString(events moira.NotificationEvents, charsForEvents int, throttled bool) string { + charsForThrottleMsg := 0 + if throttled { + charsForThrottleMsg = calcRunesCountWithoutHTML([]rune(throttleMsg)) + } + charsLeftForEvents := charsForEvents - charsForThrottleMsg + + var eventsString string + eventsString += eventsBlockStart + var tailString string + + eventsLenLimitReached := false + eventsPrinted := 0 + eventsStringLen := 0 + for _, event := range events { + line := fmt.Sprintf("\n%s", eventStringFormatter(event, formatter.location)) + if msg := event.CreateMessage(formatter.location); len(msg) > 0 { + line += fmt.Sprintf(". %s", msg) + } + + tailString = fmt.Sprintf("\n...and %d more events.", len(events)-eventsPrinted) + tailStringLen := len("\n") + utf8.RuneCountInString(tailString) + lineLen := calcRunesCountWithoutHTML([]rune(line)) + + if charsForEvents >= 0 && eventsStringLen+lineLen > charsLeftForEvents-tailStringLen { + eventsLenLimitReached = true + break + } + + eventsString += line + eventsStringLen += lineLen + eventsPrinted++ + } + eventsString += "\n" + eventsString += eventsBlockEnd + + if eventsLenLimitReached { + eventsString += tailString + } + + if throttled { + eventsString += throttleMsg + } + + return eventsString +} + +func uriFormatter(triggerURI, triggerName string) string { + return fmt.Sprintf("%s", triggerURI, html.EscapeString(triggerName)) +} + +var ( + startHeaderRegexp = regexp.MustCompile("") + endHeaderRegexp = regexp.MustCompile("") +) + +func descriptionFormatter(trigger moira.TriggerData) string { + if trigger.Desc == "" { + return "" + } + + desc := trigger.Desc + "\n" + + // Sometimes in trigger description may be text constructions like . + // blackfriday may recognise it as tag, so it won't be escaped. + // Then it is sent to telegram we will get error: 'Bad request', because telegram doesn't support such tag. + // So escaping them before blackfriday.Run. + replacer := strings.NewReplacer( + "<", "<", + ">", ">", + ) + mdWithNoTags := replacer.Replace(desc) + + htmlDescStr := string(blackfriday.Run([]byte(mdWithNoTags), + blackfriday.WithExtensions( + blackfriday.CommonExtensions & + ^blackfriday.DefinitionLists & + ^blackfriday.Tables), + blackfriday.WithRenderer( + blackfriday.NewHTMLRenderer( + blackfriday.HTMLRendererParameters{ + Flags: blackfriday.UseXHTML, + })))) + + // html headers are not supported by telegram html, so make them bold instead. + htmlDescStr = startHeaderRegexp.ReplaceAllString(htmlDescStr, "") + replacedHeaders := endHeaderRegexp.ReplaceAllString(htmlDescStr, "") + + // some tags are not supported, so replace them. + tagReplacer := strings.NewReplacer( + "

", "", + "

", "", + "", "", + "
  • ", "- ", + "
  • ", "", + "
      ", "", + "
    ", "", + "
    ", "", + "
    ", "", + "
    ", "\n", + "
    ", "\n") + + return tagReplacer.Replace(replacedHeaders) +} + +const ( + tooLongDescMessage = "\nDescription is too long for telegram sender.\n" + badFormatMessage = "\nBad trigger description for telegram sender. Please check trigger.\n" +) + +func descriptionCutter(_ string, maxSize int) string { + if utf8.RuneCountInString(tooLongDescMessage) <= maxSize { + return tooLongDescMessage + } + + return "" +} + +func boldFormatter(str string) string { + return fmt.Sprintf("%s", html.EscapeString(str)) +} + +func eventStringFormatter(event moira.NotificationEvent, loc *time.Location) string { + return fmt.Sprintf( + "%s: %s = %s (%s to %s)", + event.FormatTimestamp(loc, moira.DefaultTimeFormat), + html.EscapeString(event.Metric), + html.EscapeString(event.GetMetricsValues(moira.DefaultNotificationSettings)), + event.OldState, + event.State) +} diff --git a/senders/telegram/message_formatter_test.go b/senders/telegram/message_formatter_test.go new file mode 100644 index 000000000..f6ed08cb2 --- /dev/null +++ b/senders/telegram/message_formatter_test.go @@ -0,0 +1,199 @@ +package telegram + +import ( + "fmt" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/moira-alert/moira" + "github.com/moira-alert/moira/senders/msgformat" + + . "github.com/smartystreets/goconvey/convey" +) + +const testFrontURI = "https://moira.uri" + +func TestMessageFormatter_Format(t *testing.T) { + location, _ := time.LoadLocation("UTC") + emojiProvider := telegramEmojiProvider{} + + formatter := NewTelegramMessageFormatter( + emojiProvider, + true, + testFrontURI, + location) + + event := moira.NotificationEvent{ + TriggerID: "TriggerID", + Values: map[string]float64{"t1": 123}, + Timestamp: 150000000, + Metric: "Metric", + OldState: moira.StateOK, + State: moira.StateNODATA, + } + + const shortDesc = `My description` + trigger := moira.TriggerData{ + Tags: []string{"tag1", "tag2"}, + Name: "Name", + ID: "TriggerID", + Desc: shortDesc, + } + + expectedFirstLine := "πŸ’£ NODATA Name [tag1][tag2]\n" + lenFirstLine := utf8.RuneCountInString(expectedFirstLine) - + utf8.RuneCountInString("") + + eventStr := "02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n" + lenEventStr := utf8.RuneCountInString(eventStr) - utf8.RuneCountInString("") // 60 - 13 = 47 + + Convey("TelegramMessageFormatter", t, func() { + Convey("message with one event", func() { + events, throttled := moira.NotificationEvents{event}, false + expected := expectedFirstLine + + shortDesc + "\n" + + eventsBlockStart + "\n" + + eventStr + + eventsBlockEnd + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(msg, ShouldEqual, expected) + }) + + Convey("message with one event and throttled", func() { + events, throttled := moira.NotificationEvents{event}, true + msg := formatter.Format(getParams(events, trigger, throttled)) + + expected := expectedFirstLine + + shortDesc + "\n" + + eventsBlockStart + "\n" + + eventStr + + eventsBlockEnd + + throttleMsg + So(msg, ShouldEqual, expected) + }) + + Convey("message with 3 events", func() { + events, throttled := moira.NotificationEvents{event, event, event}, false + expected := expectedFirstLine + + shortDesc + "\n" + + eventsBlockStart + "\n" + + strings.Repeat(eventStr, 3) + + eventsBlockEnd + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(msg, ShouldEqual, expected) + }) + + Convey("message with complex description", func() { + trigger.Desc = "# ΠœΠΎΡ‘ описаниС\n\nсписок:\n- **ΠΆΠΈΡ€Π½Ρ‹ΠΉ**\n- *курсив*\n- `ΠΊΠΎΠ΄`\n- ΠΏΠΎΠ΄Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ\n- ~~Π·Π°Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ~~\n" + + "\n------\nif a > b do smth\nif c < d do another thing\ntrue && false = false\ntrue || false = true\n" + + "\"Hello everybody!\", 'another quots'\nif I use something like nothing happens, also if i use allowed tag" + events, throttled := moira.NotificationEvents{event}, false + + expected := expectedFirstLine + + "ΠœΠΎΡ‘ описаниС\n\nсписок:\n- ΠΆΠΈΡ€Π½Ρ‹ΠΉ\n- курсив\n- ΠΊΠΎΠ΄\n- <u>ΠΏΠΎΠ΄Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ</u>\n- Π·Π°Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ\n" + + "\n\n\nif a > b do smth\nif c < d do another thing\ntrue && false = false\ntrue || false = true\n" + + ""Hello everybody!", 'another quots'\nif I use something like <custom_tag> nothing happens, also if i use allowed <b> tag\n" + + eventsBlockStart + "\n" + + eventStr + + eventsBlockEnd + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(msg, ShouldEqual, expected) + }) + + Convey("with long messages", func() { + msgLimit := albumCaptionMaxCharacters - lenFirstLine + halfMsgLimit := msgLimit / 2 + greaterThanHalf := halfMsgLimit + 100 + lessThanHalf := halfMsgLimit - 100 + + Convey("text size of description > msgLimit / 2", func() { + var events moira.NotificationEvents + throttled := false + + eventsCount := lessThanHalf / lenEventStr + for i := 0; i < eventsCount; i++ { + events = append(events, event) + } + + trigger.Desc = strings.Repeat("**Ρ‘**ΠΆ", greaterThanHalf/2) + + expected := expectedFirstLine + + strings.Repeat("Ρ‘ΠΆ", greaterThanHalf/2) + "\n" + + eventsBlockStart + "\n" + + strings.Repeat(eventStr, eventsCount) + + eventsBlockEnd + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters) + So(msg, ShouldEqual, expected) + }) + + Convey("text size of events block > msgLimit / 2", func() { + var events moira.NotificationEvents + throttled := false + + eventsCount := greaterThanHalf / lenEventStr + for i := 0; i < eventsCount; i++ { + events = append(events, event) + } + + trigger.Desc = strings.Repeat("**Ρ‘**ΠΆ", lessThanHalf/2) + + expected := expectedFirstLine + + strings.Repeat("Ρ‘ΠΆ", lessThanHalf/2) + "\n" + + eventsBlockStart + "\n" + + strings.Repeat(eventStr, eventsCount) + + eventsBlockEnd + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters) + So(msg, ShouldEqual, expected) + }) + + Convey("both description and events block have text size > msgLimit/2", func() { + var events moira.NotificationEvents + throttled := false + + eventsCount := greaterThanHalf / lenEventStr + for i := 0; i < eventsCount; i++ { + events = append(events, event) + } + + trigger.Desc = strings.Repeat("**Ρ‘**ΠΆ", greaterThanHalf/2) + + eventsShouldBe := halfMsgLimit / lenEventStr + + expected := expectedFirstLine + + tooLongDescMessage + + eventsBlockStart + "\n" + + strings.Repeat(eventStr, eventsShouldBe) + + eventsBlockEnd + + fmt.Sprintf("\n...and %d more events.", len(events)-eventsShouldBe) + + msg := formatter.Format(getParams(events, trigger, throttled)) + + So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters) + So(msg, ShouldEqual, expected) + }) + }) + }) +} + +func getParams(events moira.NotificationEvents, trigger moira.TriggerData, throttled bool) msgformat.MessageFormatterParams { + return msgformat.MessageFormatterParams{ + Events: events, + Trigger: trigger, + MessageMaxChars: albumCaptionMaxCharacters, + Throttled: throttled, + } +} diff --git a/senders/telegram/send.go b/senders/telegram/send.go index 59ba82ba7..6935dff6a 100644 --- a/senders/telegram/send.go +++ b/senders/telegram/send.go @@ -77,7 +77,9 @@ func (sender *Sender) SendEvents(events moira.NotificationEvents, contact moira. } if err := sender.talk(chat, message, plots, msgType); err != nil { - return checkBrokenContactError(sender.logger, err) + err = checkBrokenContactError(sender.logger, err) + + return sender.retryIfBadMessageError(err, events, contact, trigger, plots, throttled, chat, msgType) } return nil @@ -300,3 +302,78 @@ func getMessageType(plots [][]byte) messageType { return Message } + +func (sender *Sender) retryIfBadMessageError( + err error, + events []moira.NotificationEvent, + contact moira.ContactData, + trigger moira.TriggerData, + plots [][]byte, + throttled bool, + chat *Chat, + msgType messageType, +) error { + var e moira.SenderBrokenContactError + if isBrokenContactErr := errors.As(err, &e); !isBrokenContactErr { + if _, isBadMessage := checkBadMessageError(err); isBadMessage { + // There are some problems with message formatting. + // For example, it is too long, or have unsupported tags and so on. + // Events should not be lost, so retry to send it without description. + + sender.logger.Warning(). + String(moira.LogFieldNameContactID, contact.ID). + String(moira.LogFieldNameContactType, contact.Type). + String(moira.LogFieldNameContactValue, contact.Value). + String(moira.LogFieldNameTriggerID, trigger.ID). + String(moira.LogFieldNameTriggerName, trigger.Name). + Error(err). + Msg("Failed to send alert because of bad description. Retrying now.") + + trigger.Desc = badFormatMessage + message := sender.buildMessage(events, trigger, throttled, characterLimits[msgType]) + + err = sender.talk(chat, message, plots, msgType) + return checkBrokenContactError(sender.logger, err) + } + } + + return err +} + +var badMessageFormatErrors = map[*telebot.Error]struct{}{ + telebot.ErrTooLarge: {}, + telebot.ErrTooLongMessage: {}, +} + +const ( + errMsgPrefixCannotParseInputMedia = "telegram: Bad Request: can't parse InputMedia: Can't parse entities: Unsupported start tag" + errMsgPrefixCaptionTooLong = "telegram: Bad Request: message caption is too long (400)" + errMsgPrefixCannotParseEntities = "telegram: Bad Request: can't parse entities: Unsupported start tag" +) + +func checkBadMessageError(err error) (error, bool) { + if err == nil { + return nil, false + } + + var telebotErr *telebot.Error + if ok := errors.As(err, &telebotErr); ok { + if isBadMessageFormatError(telebotErr) { + return telebotErr, true + } + } + + errMsg := err.Error() + if strings.HasPrefix(errMsg, errMsgPrefixCannotParseInputMedia) || + strings.HasPrefix(errMsg, errMsgPrefixCaptionTooLong) || + strings.HasPrefix(errMsg, errMsgPrefixCannotParseEntities) { + return err, true + } + + return err, false +} + +func isBadMessageFormatError(e *telebot.Error) bool { + _, exists := badMessageFormatErrors[e] + return exists +} diff --git a/senders/telegram/send_test.go b/senders/telegram/send_test.go index e7413021b..489784744 100644 --- a/senders/telegram/send_test.go +++ b/senders/telegram/send_test.go @@ -227,3 +227,58 @@ func TestCheckBrokenContactError(t *testing.T) { }) }) } + +func TestCheckBadMessageError(t *testing.T) { + Convey("Check bad message error", t, func() { + Convey("nil error is nil", func() { + err, ok := checkBadMessageError(nil) + + So(err, ShouldBeNil) + So(ok, ShouldBeFalse) + }) + + Convey("proper telebot errors is recognised", func() { + for givenErr := range badMessageFormatErrors { + err, ok := checkBadMessageError(givenErr) + + So(err, ShouldEqual, givenErr) + So(ok, ShouldBeTrue) + } + }) + + Convey("other telebot errors are not recognised", func() { + otherErrors := []*telebot.Error{ + telebot.ErrInternal, + telebot.ErrEmptyMessage, + telebot.ErrWrongFileID, + telebot.ErrNoRightsToDelete, + telebot.ErrCantRemoveOwner, + telebot.ErrUnauthorized, + telebot.ErrNoRightsToSendPhoto, + telebot.ErrChatNotFound, + } + + for _, otherError := range otherErrors { + err, ok := checkBadMessageError(otherError) + + So(err, ShouldEqual, otherError) + So(ok, ShouldBeFalse) + } + }) + + Convey("errors with proper message is recognised", func() { + givenErrors := []error{ + fmt.Errorf("telegram: Bad Request: can't parse InputMedia: Can't parse entities: Unsupported start tag \"sup\" at byte offset 396 (400)"), + fmt.Errorf("telegram: Bad Request: message caption is too long (400)"), + fmt.Errorf("telegram: Bad Request: can't parse entities: Unsupported start tag \"container_name\" at byte offset 729 (400)"), + } + + for _, givenErr := range givenErrors { + err, ok := checkBadMessageError(givenErr) + + So(err, ShouldEqual, givenErr) + So(ok, ShouldBeTrue) + } + }) + }) +}