improve telegram notifications

These changes are designed to ease the transition into
the simplified telegram notifications verification model.
This commit is contained in:
Dmitry Verkhoturov
2021-11-01 14:35:37 -05:00
committed by Umputun
parent c852ea4834
commit 6449b7d92b
4 changed files with 175 additions and 165 deletions
+52 -47
View File
@@ -472,23 +472,26 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
KeyStore: adminStore,
}
var emailNotifications bool
notifyService, telegramBotUsername, err := s.makeNotify(dataService, authenticator)
if contains("email", s.Notify.Users) {
emailNotifications = true
}
// we pass telegramBotUsername to Rest server only if user notifications are enabled
if !contains("telegram", s.Notify.Users) {
telegramBotUsername = ""
}
var telegramService *notify.Telegram
var telegramBotUsername string
notifyDestinations, err := s.makeNotifyDestinations(authenticator)
if err != nil {
log.Printf("[WARN] failed to make notify service, %s", err)
notifyService = notify.NopService // disable notifier
emailNotifications = false // email notifications are not available in this case
telegramBotUsername = "" // telegram notifications are not available in this case either
log.Printf("[WARN] failed to prepare notify destinations, %s", err)
}
if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) {
telegramService, err = s.makeTelegramNotify()
if err != nil {
log.Printf("[WARN] failed to make telegram notify service, %s", err)
} else {
notifyDestinations = append(notifyDestinations, telegramService)
}
}
notifyService := s.makeNotifyService(dataService, notifyDestinations)
if telegramService != nil {
telegramBotUsername = telegramService.GetBotUsername()
}
imgProxy := &proxy.Image{
@@ -526,7 +529,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
SSLConfig: sslConfig,
UpdateLimiter: s.UpdateLimit,
ImageService: imageService,
EmailNotifications: emailNotifications,
EmailNotifications: contains("email", s.Notify.Users),
TelegramBotUsername: telegramBotUsername,
EmojiEnabled: s.EnableEmoji,
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
@@ -912,11 +915,17 @@ func (s *ServerCommand) loadEmailTemplate() (string, error) {
return string(file), nil
}
// aside from notify.Service and error, returns telegram bot name which will be passed to the frontend
func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *auth.Service) (*notify.Service, string, error) {
notifyService := notify.NopService
var destinations []notify.Destination
var telegramBotUsername string
func (s *ServerCommand) makeNotifyService(dataStore *service.DataStore, destinations []notify.Destination) *notify.Service {
if len(destinations) > 0 {
log.Printf("[INFO] make notify, for users: %s, for admins: %s", s.Notify.Users, s.Notify.Admins)
return notify.NewService(dataStore, s.Notify.QueueSize, destinations...)
}
return notify.NopService
}
// constructs list of notify destinations except for telegram, returns empty list in case of error
func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]notify.Destination, error) {
destinations := make([]notify.Destination, 0)
if contains("webhook", s.Notify.Admins) {
client := &http.Client{Timeout: 5 * time.Second}
@@ -932,7 +941,7 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
}
webhook, err := notify.NewWebhook(client, whParams)
if err != nil {
return nil, "", errors.Wrap(err, "failed to create webhook notification destination")
return destinations, errors.Wrap(err, "failed to create webhook notification destination")
}
destinations = append(destinations, webhook)
}
@@ -940,29 +949,11 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
if contains("slack", s.Notify.Admins) {
slack, err := notify.NewSlack(s.Notify.Slack.Token, s.Notify.Slack.Channel)
if err != nil {
return nil, "", errors.Wrap(err, "failed to create slack notification destination")
return destinations, errors.Wrap(err, "failed to create slack notification destination")
}
destinations = append(destinations, slack)
}
if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) {
if contains("telegram", s.Notify.Admins) && s.Notify.Telegram.Channel == "" {
return nil, "", errors.New("--notify.telegram.channel must be set for admin notifications to work")
}
telegramParams := notify.TelegramParams{
AdminChannelID: s.Notify.Telegram.Channel,
UserNotifications: contains("telegram", s.Notify.Users),
Token: s.Telegram.Token,
Timeout: s.Telegram.Timeout,
}
tg, err := notify.NewTelegram(telegramParams)
if err != nil {
return nil, "", errors.Wrap(err, "failed to create telegram notification destination")
}
destinations = append(destinations, tg)
telegramBotUsername = tg.BotUsername
}
// with logic below admin notifications enable notifications for users on the backend even if they
// are not enabled explicitly, however they won't be visible to the users in the frontend
// because api.Rest.EmailNotifications would be set to false.
@@ -1004,16 +995,30 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
}
emailService, err := notify.NewEmail(emailParams, smtpParams)
if err != nil {
return nil, "", errors.Wrap(err, "failed to create email notification destination")
return destinations, errors.Wrap(err, "failed to create email notification destination")
}
destinations = append(destinations, emailService)
}
if len(destinations) > 0 {
log.Printf("[INFO] make notify, for users: %s, for admins: %s", s.Notify.Users, s.Notify.Admins)
notifyService = notify.NewService(dataStore, s.Notify.QueueSize, destinations...)
return destinations, nil
}
// constructs Telegram notify service
func (s *ServerCommand) makeTelegramNotify() (*notify.Telegram, error) {
if contains("telegram", s.Notify.Admins) && s.Notify.Telegram.Channel == "" {
return nil, errors.New("--notify.telegram.channel must be set for admin notifications to work")
}
return notifyService, telegramBotUsername, nil
telegramParams := notify.TelegramParams{
AdminChannelID: s.Notify.Telegram.Channel,
UserNotifications: contains("telegram", s.Notify.Users),
Token: s.Telegram.Token,
Timeout: s.Telegram.Timeout,
}
tg, err := notify.NewTelegram(telegramParams)
if err != nil {
return nil, errors.Wrap(err, "failed to create telegram notification destination")
}
return tg, nil
}
func (s *ServerCommand) makeSSLConfig() (config api.SSLConfig, err error) {
+80 -83
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@@ -23,7 +24,6 @@ type TelegramParams struct {
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
Token string // token for telegram bot API interactions
Timeout time.Duration // http client timeout
BotUsername string // filled with bot username after Telegram creation, used in frontend
UserNotifications bool // flag which enables user notifications
apiPrefix string // changed only in tests
@@ -32,6 +32,8 @@ type TelegramParams struct {
// Telegram implements notify.Destination for telegram
type Telegram struct {
TelegramParams
username string // bot username
}
// telegramMsg is used to send message trough Telegram bot API
@@ -40,12 +42,9 @@ type telegramMsg struct {
ParseMode string `json:"parse_mode,omitempty"`
}
// TelegramBotInfo structure contains information about telegram bot
// TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response
type TelegramBotInfo struct {
ID uint64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
Username string `json:"username"`
Username string `json:"username"`
}
const telegramTimeOut = 5000 * time.Millisecond
@@ -66,46 +65,13 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
client := http.Client{Timeout: res.Timeout}
resp, err := client.Get(fmt.Sprintf("%s%s/getMe", res.apiPrefix, res.Token))
if err != nil {
return errors.Wrap(err, "can't initialize telegram notifications")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
}
}()
botInfo, err := res.botInfo(ctx)
if err != nil {
return nil, errors.Wrapf(err, "can't retrieve bot info from Telegram API")
}
res.username = botInfo.Username
if resp.StatusCode != http.StatusOK {
tgErr := struct {
Description string `json:"description"`
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgErr); err == nil {
return errors.Errorf("unexpected telegram API status code %d, error: %q", resp.StatusCode, tgErr.Description)
}
return errors.Errorf("unexpected telegram API status code %d", resp.StatusCode)
}
tgResp := struct {
OK bool `json:"ok"`
Result TelegramBotInfo
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode response")
}
if !tgResp.OK || !tgResp.Result.IsBot {
return errors.Errorf("unexpected telegram response %+v", tgResp)
}
res.BotUsername = tgResp.Result.Username
return nil
})
return &res, err
return &res, nil
}
// Send to telegram recipients
@@ -141,44 +107,8 @@ func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) err
chatID = "@" + chatID // if chatID not a number enforce @ prefix
}
u := fmt.Sprintf("%s%s/sendMessage?chat_id=%s&disable_web_page_preview=true",
t.apiPrefix, t.Token, chatID)
r, err := http.NewRequest("POST", u, bytes.NewReader(b))
if err != nil {
return errors.Wrap(err, "failed to make telegram request")
}
r.Header.Set("Content-Type", "application/json; charset=utf-8")
client := http.Client{Timeout: t.Timeout}
r = r.WithContext(ctx)
resp, err := client.Do(r)
if err != nil {
return errors.Wrap(err, "failed to get telegram response")
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("[WARN] can't close request body, %s", err)
}
}()
if resp.StatusCode != http.StatusOK {
tgErr := struct {
Description string `json:"description"`
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgErr); err == nil {
return errors.Errorf("unexpected telegram API status code %d, error: %q", resp.StatusCode, tgErr.Description)
}
return errors.Errorf("unexpected telegram API status code %d", resp.StatusCode)
}
tgResp := struct {
OK bool `json:"ok"`
}{}
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
return errors.Wrap(err, "can't decode telegram response")
}
return nil
url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID)
return t.request(ctx, url, b, &struct{}{})
}
// buildMessage generates message for generic notification about new comment
@@ -264,6 +194,11 @@ func (t *Telegram) buildVerificationMessage(user, token, site string) ([]byte, e
return b, nil
}
// GetBotUsername returns bot username
func (t *Telegram) GetBotUsername() string {
return t.username
}
func (t *Telegram) String() string {
result := "telegram"
if t.AdminChannelID != "" {
@@ -274,3 +209,65 @@ func (t *Telegram) String() string {
}
return result
}
// botInfo returns info about configured bot
func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) {
var resp = struct {
Result *TelegramBotInfo `json:"result"`
}{}
err := t.request(ctx, "getMe", nil, &resp)
if err != nil {
return nil, err
}
if resp.Result == nil {
return nil, errors.New("received empty result")
}
return resp.Result, nil
}
func (t *Telegram) request(ctx context.Context, method string, b []byte, data interface{}) error {
return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error {
url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method)
var req *http.Request
var err error
if b == nil {
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
} else {
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
if err != nil {
return errors.Wrap(err, "failed to create request")
}
client := http.Client{Timeout: t.Timeout}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to send request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return t.parseError(resp.Body, resp.StatusCode)
}
if err = json.NewDecoder(resp.Body).Decode(data); err != nil {
return errors.Wrap(err, "failed to decode json response")
}
return nil
})
}
func (t *Telegram) parseError(r io.Reader, statusCode int) error {
tgErr := struct {
Description string `json:"description"`
}{}
if err := json.NewDecoder(r).Decode(&tgErr); err != nil {
return errors.Errorf("unexpected telegram API status code %d", statusCode)
}
return errors.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
}
+41 -33
View File
@@ -15,8 +15,7 @@ import (
)
func TestTelegram_New(t *testing.T) {
ts := mockTelegramServer()
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
@@ -29,15 +28,14 @@ func TestTelegram_New(t *testing.T) {
assert.Equal(t, tb.Timeout, time.Second*5)
assert.Equal(t, "remark_test", tb.AdminChannelID, "@ added")
st := time.Now()
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "bad-resp",
Token: "empty-json",
apiPrefix: ts.URL + "/",
})
assert.EqualError(t, err, "unexpected telegram response {OK:false Result:{ID:707381019 IsBot:false FirstName:comments_test Username:remark42_test_bot}}")
assert.True(t, time.Since(st) >= 250*5*time.Millisecond)
assert.EqualError(t, err, "can't retrieve bot info from Telegram API: received empty result")
st := time.Now()
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "non-json-resp",
@@ -45,7 +43,8 @@ func TestTelegram_New(t *testing.T) {
apiPrefix: ts.URL + "/",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "can't decode response:")
assert.Contains(t, err.Error(), "failed to decode json response:")
assert.True(t, time.Since(st) >= 250*3*time.Millisecond)
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
@@ -53,7 +52,7 @@ func TestTelegram_New(t *testing.T) {
Timeout: 2 * time.Second,
apiPrefix: ts.URL + "/",
})
assert.EqualError(t, err, "unexpected telegram API status code 404")
assert.EqualError(t, err, "can't retrieve bot info from Telegram API: unexpected telegram API status code 404")
_, err = NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
@@ -61,7 +60,7 @@ func TestTelegram_New(t *testing.T) {
apiPrefix: "http://127.0.0.1:4321/",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "can't initialize telegram notifications")
assert.Contains(t, err.Error(), "can't retrieve bot info from Telegram API")
assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused")
_, err = NewTelegram(TelegramParams{
@@ -89,8 +88,22 @@ func TestTelegram_New(t *testing.T) {
assert.Equal(t, "1234567890", tb.AdminChannelID, "no @ prefix")
}
func TestTelegram_GetBotUsername(t *testing.T) {
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
AdminChannelID: "remark_test",
Token: "good-token",
apiPrefix: ts.URL + "/",
})
assert.NoError(t, err)
assert.NotNil(t, tb)
assert.Equal(t, "remark42_test_bot", tb.GetBotUsername())
}
func TestTelegram_Send(t *testing.T) {
ts := mockTelegramServer()
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
@@ -124,7 +137,15 @@ func TestTelegram_Send(t *testing.T) {
UserNotifications: true,
apiPrefix: ts.URL + "/",
})
assert.Nil(t, tb)
assert.Error(t, err, "should fail")
tb = &Telegram{
TelegramParams: TelegramParams{
AdminChannelID: "remark_test",
Token: "non-json-resp",
UserNotifications: true,
apiPrefix: ts.URL + "/",
}}
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected telegram API status code 404", "send on broken tg")
@@ -147,7 +168,7 @@ func TestTelegram_Send(t *testing.T) {
}
func TestTelegram_SendVerification(t *testing.T) {
ts := mockTelegramServer()
ts := mockTelegramServer(nil)
defer ts.Close()
tb, err := NewTelegram(TelegramParams{
@@ -182,37 +203,24 @@ func TestTelegram_SendVerification(t *testing.T) {
assert.Contains(t, string(res), `secret_`)
}
func mockTelegramServer() *httptest.Server {
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": true,
const getMeResp = `{"ok": true,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": true,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
func mockTelegramServer(_ http.HandlerFunc) *httptest.Server {
router := chi.NewRouter()
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(getMeResp))
})
router.Get("/bad-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `{"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
}}`
_, _ = w.Write([]byte(s))
router.Get("/empty-json/getMe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{}`))
})
router.Get("/non-json-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
s := `"ok": false,
"result": {
"first_name": "comments_test",
"id": 707381019,
"is_bot": false,
"username": "remark42_test_bot"
`
_, _ = w.Write([]byte(s))
_, _ = w.Write([]byte(`not-a-json`))
})
router.Get("/404/getMe", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
+2 -2
View File
@@ -605,8 +605,8 @@ func TestRest_EmailAndTelegram(t *testing.T) {
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},