From 498073c50949c3e839f22e941de8f73e02001c1a Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sat, 16 Oct 2021 17:40:05 +0200 Subject: [PATCH] send telegram messages in HTML mode That resolves problem with inability to properly render message text in the resulting telegram message due to markdown escaping trickiness. --- backend/app/notify/telegram.go | 57 +++++++++++++++-------------- backend/app/notify/telegram_test.go | 37 ++++++------------- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 3ae63a3b..6ae627b2 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -5,13 +5,13 @@ import ( "context" "encoding/json" "fmt" - "html" "net/http" "strconv" "strings" "time" "github.com/hashicorp/go-multierror" + "github.com/microcosm-cc/bluemonday" log "github.com/go-pkgz/lgr" "github.com/go-pkgz/repeater" @@ -110,7 +110,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error { log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID) result := new(multierror.Error) - msg, err := buildTelegramMessage(req) + msg, err := buildMessage(req) if err != nil { return errors.Wrapf(err, "failed to make telegram message body for comment ID %s", req.Comment.ID) } @@ -178,26 +178,27 @@ func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) err return nil } -func buildTelegramMessage(req Request) ([]byte, error) { +// buildMessage generates message for generic notification about new comment +func buildMessage(req Request) ([]byte, error) { commentURLPrefix := req.Comment.Locator.URL + uiNav - msg := fmt.Sprintf("[%s](%s)", escapeText(req.Comment.User.Name), commentURLPrefix+req.Comment.ID) + msg := fmt.Sprintf(`%s`, commentURLPrefix+req.Comment.ID, escapeTelegramText(req.Comment.User.Name)) if req.Comment.ParentID != "" { - msg += fmt.Sprintf(" -> [%s](%s)", escapeText(req.parent.User.Name), commentURLPrefix+req.parent.ID) + msg += fmt.Sprintf(" -> %s", commentURLPrefix+req.parent.ID, escapeTelegramText(req.parent.User.Name)) } - msg += fmt.Sprintf("\n\n%s", escapeText(req.Comment.Orig)) + msg += fmt.Sprintf("\n\n%s", telegramSupportedHTML(req.Comment.Text)) if req.Comment.ParentID != "" { - msg += fmt.Sprintf("\n\n> \"_%s_\"", escapeText(req.parent.Orig)) + msg += fmt.Sprintf("\n\n \"_%s_\"", telegramSupportedHTML(req.parent.Text)) } if req.Comment.PostTitle != "" { - msg += fmt.Sprintf("\n\n↦ [%s](%s)", escapeText(req.Comment.PostTitle), req.Comment.Locator.URL) + msg += fmt.Sprintf("\n\n↦ %s", req.Comment.Locator.URL, escapeTelegramText(req.Comment.PostTitle)) } - body := telegramMsg{Text: msg, ParseMode: "MarkdownV2"} + body := telegramMsg{Text: msg, ParseMode: "HTML"} b, err := json.Marshal(body) if err != nil { return nil, err @@ -205,22 +206,24 @@ func buildTelegramMessage(req Request) ([]byte, error) { return b, nil } -func escapeText(text string) string { - escSymbols := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} - res := html.UnescapeString(text) - for _, esc := range escSymbols { - res = strings.Replace(res, esc, "\\"+esc, -1) - } - return res +// returns HTML with only tags allowed in Telegram HTML message payload +// https://core.telegram.org/bots/api#html-style +func telegramSupportedHTML(htmlText string) string { + p := bluemonday.NewPolicy() + p.AllowElements("b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "a", "code", "pre") + p.AllowAttrs("href").OnElements("a") + p.AllowAttrs("class").OnElements("code") + return p.Sanitize(htmlText) } -func escapeCode(text string) string { - escSymbols := []string{"`", `\`} - res := text - for _, esc := range escSymbols { - res = strings.Replace(res, esc, "\\"+esc, -1) - } - return res +// returns text sanitized of symbols not allowed inside other HTML tags in Telegram HTML message payload +// https://core.telegram.org/bots/api#html-style +func escapeTelegramText(text string) string { + // order is important + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + return text } // SendVerification sends user verification message to the specified user @@ -246,11 +249,11 @@ func (t *Telegram) SendVerification(ctx context.Context, req VerificationRequest // buildVerificationMessage generates verification telegram message based on given input func (t *Telegram) buildVerificationMessage(user, token, site string) ([]byte, error) { - result := fmt.Sprintf("Confirmation for *%s* on site %s\n"+ + result := fmt.Sprintf("Confirmation for %s on site %s\n"+ "Please copy and paste this text into “token” field on comments page to confirm subscription:\n\n\n"+ - "```%s```", - escapeText(user), escapeText(site), escapeCode(token)) - body := telegramMsg{Text: result, ParseMode: "MarkdownV2"} + "
%s
", + escapeTelegramText(user), escapeTelegramText(site), escapeTelegramText(token)) + body := telegramMsg{Text: result, ParseMode: "HTML"} b, err := json.Marshal(body) if err != nil { return nil, err diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 514c3c73..115942b4 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -4,7 +4,6 @@ import ( "context" "net/http" "net/http/httptest" - "strconv" "testing" "time" @@ -102,9 +101,9 @@ func TestTelegram_Send(t *testing.T) { }) assert.NoError(t, err) assert.NotNil(t, tb) - c := store.Comment{Text: "some text", ParentID: "1", ID: "999"} + c := store.Comment{Text: "some text", ParentID: "1", ID: "999", Locator: store.Locator{URL: "http://example.org/"}} c.User.Name = "from" - cp := store.Comment{Text: "some parent text"} + cp := store.Comment{Text: `

some parent text with a link and special text:
& < > &

`} cp.User.Name = "to" err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) @@ -136,6 +135,15 @@ func TestTelegram_Send(t *testing.T) { tb.apiPrefix = "http://non-existent" err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) assert.Error(t, err) + + // test buildMessage separately for message text + res, err := buildMessage(Request{Comment: c, parent: cp}) + assert.NoError(t, err) + assert.Equal(t, `{"text":"\u003ca href=\"http://example.org/#remark42__comment-999\"\u003efrom\u003c/a\u003e -\u003e \u003ca href=\"http://example.org/#remark42__comment-\"\u003eto\u003c/a\u003e\n\n`+ + `some text\n\n`+ + ` \"_some parent text with a \u003ca href=\"http://example.org\"\u003elink\u003c/a\u003e and special text:\u0026amp; \u0026lt; \u0026gt; \u0026amp;_\"\n\n`+ + `↦ \u003ca href=\"http://example.org/\"\u003e[test title]\u003c/a\u003e","parse_mode":"HTML"}`, + string(res)) } func TestTelegram_SendVerification(t *testing.T) { @@ -170,7 +178,7 @@ func TestTelegram_SendVerification(t *testing.T) { // test buildVerificationMessage separately for message text res, err := tb.buildVerificationMessage(req.User, req.Token, req.SiteID) assert.NoError(t, err) - assert.Contains(t, string(res), `Confirmation for *test\\_username* on site remark`) + assert.Contains(t, string(res), `Confirmation for \u003ci\u003etest_username\u003c/i\u003e on site remark`) assert.Contains(t, string(res), `secret_`) } @@ -216,24 +224,3 @@ func mockTelegramServer() *httptest.Server { return httptest.NewServer(router) } - -func Test_escapeTitle(t *testing.T) { - tbl := []struct { - inp string - out string - }{ - {"", ""}, - {"something 123", "something 123"}, - {"something [123]", "something \\[123\\]"}, - {"something (123)", "something \\(123\\)"}, - {"something (123) [aaa]", "something \\(123\\) \\[aaa\\]"}, - } - - for i, tt := range tbl { - tt := tt - t.Run(strconv.Itoa(i), func(t *testing.T) { - assert.Equal(t, tt.out, escapeText(tt.inp)) - }) - } - -}