Repository: moira-alert/moira (go)

Issue:
Markdown descriptions in senders
Web UI supports Markdown descriptions since 2.5 (see #285), but Slack and other senders do not. We need to send descriptions as Markdown in all sender types that support it. And also maybe we need to convert Markdown for senders that support HTML or other types of markup languages.

Pull request description:
feat: trigger description to telegram alerts
# Add formatted description to telegram sender

Description is formatted with html.

Sender will try to include trigger description to telegram alert. But if one of this conditions is satisfied it can be replaced with other text:
- if characters count of message is greater than telegram limits and/or total characters count (with html tags) is greater than telegram limits, then the description will be replaced with: `Description is too long for telegram sender.`
- if first attemt of sending alert failed because of message formatting reasons, then the description will be replaced with: `Bad trigger description for telegram sender. Please check trigger.`

Closes #353


Patch:
diff --git a/senders/mattermost/sender.go b/senders/mattermost/sender.go
index d83dbf6c..36cbabb3 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 15660995..9a78403e 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/msgformat.go b/senders/msgformat/msgformat.go
index 5d00724b..72a6cdb3 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 54f960c9..28976c6c 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/telegram/init.go b/senders/telegram/init.go
index 7815437e..4659c1f6 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 = "<blockquote expandable>"
-	codeBlockEnd   = "</blockquote>"
-)
-
 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("<a href=\"%s\">%s</a>", triggerURI, html.EscapeString(triggerName))
-}
-
-func emptyDescriptionFormatter(trigger moira.TriggerData) string {
-	return ""
-}
-
-func boldFormatter(str string) string {
-	return fmt.Sprintf("<b>%s</b>", html.EscapeString(str))
-}
-
-func eventStringFormatter(event moira.NotificationEvent, loc *time.Location) string {
-	return fmt.Sprintf(
-		"%s: <code>%s</code> = %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 00000000..8059a223
--- /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 = "<blockquote>"
+	eventsBlockEnd   = "</blockquote>"
+)
+
+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 `&gt;`, `&lt;` etc. are counted not as one symbol, for example, len([]rune("&gt;")).
+// 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("<a href=\"%s\">%s</a>", triggerURI, html.EscapeString(triggerName))
+}
+
+var (
+	startHeaderRegexp = regexp.MustCompile("<h[0-9]+>")
+	endHeaderRegexp   = regexp.MustCompile("</h[0-9]+>")
+)
+
+func descriptionFormatter(trigger moira.TriggerData) string {
+	if trigger.Desc == "" {
+		return ""
+	}
+
+	desc := trigger.Desc + "\n"
+
+	// Sometimes in trigger description may be text constructions like <param>.
+	// 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(
+		"<", "&lt;",
+		">", "&gt;",
+	)
+	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, "<b>")
+	replacedHeaders := endHeaderRegexp.ReplaceAllString(htmlDescStr, "</b>")
+
+	// some tags are not supported, so replace them.
+	tagReplacer := strings.NewReplacer(
+		"<p>", "",
+		"</p>", "",
+		"<ul>", "",
+		"</ul>", "",
+		"<li>", "- ",
+		"</li>", "",
+		"<ol>", "",
+		"</ol>", "",
+		"<hr>", "",
+		"<hr />", "",
+		"<br>", "\n",
+		"<br />", "\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("<b>%s</b>", html.EscapeString(str))
+}
+
+func eventStringFormatter(event moira.NotificationEvent, loc *time.Location) string {
+	return fmt.Sprintf(
+		"%s: <code>%s</code> = %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/send.go b/senders/telegram/send.go
index 59ba82ba..6935dff6 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
+}


Test patch:
diff --git a/senders/msgformat/highlighter_test.go b/senders/msgformat/highlighter_test.go
index 79bfaf85..470db230 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/slack/slack_test.go b/senders/slack/slack_test.go
index ac58d2f6..43471896 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/message_formatter_test.go b/senders/telegram/message_formatter_test.go
new file mode 100644
index 00000000..f6ed08cb
--- /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 := "💣 <b>NODATA</b> <a href=\"https://moira.uri/trigger/TriggerID\">Name</a> [tag1][tag2]\n"
+	lenFirstLine := utf8.RuneCountInString(expectedFirstLine) -
+		utf8.RuneCountInString("<b></b><a href=\"https://moira.uri/trigger/TriggerID\"></a>")
+
+	eventStr := "02:40 (GMT+00:00): <code>Metric</code> = 123 (OK to NODATA)\n"
+	lenEventStr := utf8.RuneCountInString(eventStr) - utf8.RuneCountInString("<code></code>") // 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- <u>подчёркнутый</u>\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"
+			events, throttled := moira.NotificationEvents{event}, false
+
+			expected := expectedFirstLine +
+				"<b>Моё описание</b>\n\nсписок:\n- <strong>жирный</strong>\n- <em>курсив</em>\n- <code>код</code>\n- &lt;u&gt;подчёркнутый&lt;/u&gt;\n- <del>зачёркнутый</del>\n" +
+				"\n\n\nif a &gt; b do smth\nif c &lt; d do another thing\ntrue &amp;&amp; false = false\ntrue || false = true\n" +
+				"&quot;Hello everybody!&quot;, 'another quots'\nif I use something like &lt;custom_tag&gt; nothing happens, also if i use allowed &lt;b&gt; 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("<strong>ё</strong>ж", 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("<strong>ё</strong>ж", 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_test.go b/senders/telegram/send_test.go
index e7413021..48978474 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)
+			}
+		})
+	})
+}