enable telegram notify trough writing bot a message
Previously it was done through writing bot first, clicking a button, copying the token, and pasting it into the web interface. The new flow is way simpler: click the link to write bot a message, then click the "Check" button in the web UI and you got notifications enabled.
This commit is contained in:
committed by
Umputun
parent
e1a2374a2d
commit
c027dcd765
+21
-13
@@ -31,6 +31,7 @@ import (
|
||||
|
||||
"github.com/umputun/remark42/backend/app/migrator"
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
"github.com/umputun/remark42/backend/app/providers"
|
||||
"github.com/umputun/remark42/backend/app/rest/api"
|
||||
"github.com/umputun/remark42/backend/app/rest/proxy"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
@@ -477,8 +478,9 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
log.Printf("[WARN] failed to prepare notify destinations, %s", err)
|
||||
}
|
||||
|
||||
// telegramService is used for getting user replies for both notifications and authorization
|
||||
var telegramService *notify.Telegram
|
||||
if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) {
|
||||
if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) || s.Auth.Telegram {
|
||||
telegramService, err = s.makeTelegramNotify()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to make telegram notify service, %s", err)
|
||||
@@ -487,8 +489,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
|
||||
var telegramBotUsername string
|
||||
if telegramService != nil {
|
||||
if contains("telegram", s.Notify.Users) {
|
||||
telegramBotUsername = telegramService.GetBotUsername()
|
||||
}
|
||||
notifyDestinations = append(notifyDestinations, telegramService)
|
||||
telegramBotUsername = telegramService.GetBotUsername()
|
||||
// start generic update for both notify and auth services
|
||||
go providers.DispatchTelegramUpdates(ctx, telegramService, []providers.TGUpdatesReceiver{telegramService}, time.Second*5)
|
||||
}
|
||||
|
||||
notifyService := s.makeNotifyService(dataService, notifyDestinations)
|
||||
@@ -525,6 +531,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
Authenticator: authenticator,
|
||||
Cache: loadingCache,
|
||||
NotifyService: notifyService,
|
||||
TelegramService: telegramService,
|
||||
SSLConfig: sslConfig,
|
||||
UpdateLimiter: s.UpdateLimit,
|
||||
ImageService: imageService,
|
||||
@@ -780,34 +787,34 @@ func (s *ServerCommand) makeCache() (LoadingCache, error) {
|
||||
|
||||
func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *auth.Service) error {
|
||||
|
||||
providers := 0
|
||||
providersCount := 0
|
||||
if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" {
|
||||
authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Github.CID != "" && s.Auth.Github.CSEC != "" {
|
||||
authenticator.AddProvider("github", s.Auth.Github.CID, s.Auth.Github.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Facebook.CID != "" && s.Auth.Facebook.CSEC != "" {
|
||||
authenticator.AddProvider("facebook", s.Auth.Facebook.CID, s.Auth.Facebook.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" {
|
||||
authenticator.AddProvider("microsoft", s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" {
|
||||
authenticator.AddProvider("yandex", s.Auth.Yandex.CID, s.Auth.Yandex.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Twitter.CID != "" && s.Auth.Twitter.CSEC != "" {
|
||||
authenticator.AddProvider("twitter", s.Auth.Twitter.CID, s.Auth.Twitter.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Patreon.CID != "" && s.Auth.Patreon.CSEC != "" {
|
||||
authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC)
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Telegram {
|
||||
telegram := &provider.TelegramHandler{
|
||||
@@ -828,13 +835,13 @@ func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *aut
|
||||
}()
|
||||
authenticator.AddCustomHandler(telegram)
|
||||
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
|
||||
if s.Auth.Dev {
|
||||
log.Print("[INFO] dev access enabled")
|
||||
authenticator.AddProvider("dev", "", "")
|
||||
providers++
|
||||
providersCount++
|
||||
}
|
||||
|
||||
if s.Auth.Email.Enable {
|
||||
@@ -886,7 +893,7 @@ func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *aut
|
||||
}))
|
||||
}
|
||||
|
||||
if providers == 0 {
|
||||
if providersCount == 0 {
|
||||
log.Printf("[WARN] no auth providers defined")
|
||||
}
|
||||
|
||||
@@ -1012,6 +1019,7 @@ func (s *ServerCommand) makeTelegramNotify() (*notify.Telegram, error) {
|
||||
UserNotifications: contains("telegram", s.Notify.Users),
|
||||
Token: s.Telegram.Token,
|
||||
Timeout: s.Telegram.Timeout,
|
||||
SuccessMsg: "✅ You have successfully subscribed for notifications, check the web!",
|
||||
}
|
||||
tg, err := notify.NewTelegram(telegramParams)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,11 +51,10 @@ type Request struct {
|
||||
|
||||
// VerificationRequest notification for user
|
||||
type VerificationRequest struct {
|
||||
SiteID string
|
||||
User string
|
||||
Email string // if set, send email only
|
||||
Telegram string // if set, send telegram only
|
||||
Token string
|
||||
SiteID string
|
||||
User string
|
||||
Email string // if set, send email only
|
||||
Token string
|
||||
}
|
||||
|
||||
const defaultQueueSize = 100
|
||||
|
||||
+226
-39
@@ -7,8 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
@@ -21,10 +24,11 @@ import (
|
||||
|
||||
// TelegramParams contain settings for telegram notifications
|
||||
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
|
||||
UserNotifications bool // flag which enables user notifications
|
||||
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
|
||||
UserNotifications bool // flag which enables user notifications
|
||||
ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot
|
||||
|
||||
apiPrefix string // changed only in tests
|
||||
}
|
||||
@@ -33,7 +37,18 @@ type TelegramParams struct {
|
||||
type Telegram struct {
|
||||
TelegramParams
|
||||
|
||||
username string // bot username
|
||||
// Identifier of the first update to be requested.
|
||||
// Should be equal to LastSeenUpdateID + 1
|
||||
// See https://core.telegram.org/bots/api#getupdates
|
||||
updateOffset int
|
||||
apiPollInterval time.Duration // interval to check updates from Telegram API and answer to users
|
||||
expiredCleanupInterval time.Duration // interval to check and clean up expired notification requests
|
||||
username string // bot username
|
||||
run int32 // non-zero if Run goroutine has started
|
||||
requests struct {
|
||||
sync.RWMutex
|
||||
data map[string]tgAuthRequest
|
||||
}
|
||||
}
|
||||
|
||||
// telegramMsg is used to send message trough Telegram bot API
|
||||
@@ -42,6 +57,14 @@ type telegramMsg struct {
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type tgAuthRequest struct {
|
||||
confirmed bool // whether login request has been confirmed and user info set
|
||||
expires time.Time
|
||||
telegramID string
|
||||
user string
|
||||
site string
|
||||
}
|
||||
|
||||
// TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response
|
||||
type TelegramBotInfo struct {
|
||||
Username string `json:"username"`
|
||||
@@ -49,6 +72,8 @@ type TelegramBotInfo struct {
|
||||
|
||||
const telegramTimeOut = 5000 * time.Millisecond
|
||||
const telegramAPIPrefix = "https://api.telegram.org/bot"
|
||||
const tgPollInterval = time.Second * 5
|
||||
const tgCleanupInterval = time.Minute * 5
|
||||
|
||||
// NewTelegram makes telegram bot for notifications
|
||||
func NewTelegram(params TelegramParams) (*Telegram, error) {
|
||||
@@ -60,6 +85,8 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
|
||||
if res.Timeout == 0 {
|
||||
res.Timeout = telegramTimeOut
|
||||
}
|
||||
res.apiPollInterval = tgPollInterval
|
||||
res.expiredCleanupInterval = tgCleanupInterval
|
||||
log.Printf("[DEBUG] create new telegram notifier for api=%s, timeout=%s", res.apiPrefix, res.Timeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -71,6 +98,8 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
|
||||
}
|
||||
res.username = botInfo.Username
|
||||
|
||||
res.requests.data = make(map[string]tgAuthRequest)
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
@@ -108,7 +137,7 @@ func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID)
|
||||
return t.request(ctx, url, b, &struct{}{})
|
||||
return t.Request(ctx, url, b, &struct{}{})
|
||||
}
|
||||
|
||||
// buildMessage generates message for generic notification about new comment
|
||||
@@ -159,39 +188,24 @@ func escapeTelegramText(text string) string {
|
||||
return text
|
||||
}
|
||||
|
||||
// SendVerification sends user verification message to the specified user
|
||||
func (t *Telegram) SendVerification(ctx context.Context, req VerificationRequest) error {
|
||||
if req.Telegram == "" {
|
||||
// this means we can't send this request via Telegram
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.Errorf("sending message to %q aborted due to canceled context", req.User)
|
||||
default:
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] send verification via %s, user %s", t, req.User)
|
||||
msg, err := t.buildVerificationMessage(req.User, req.Token, req.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return t.sendMessage(ctx, msg, req.Telegram)
|
||||
// SendVerification is not needed for telegram
|
||||
func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildVerificationMessage generates verification telegram message based on given input
|
||||
func (t *Telegram) buildVerificationMessage(user, token, site string) ([]byte, error) {
|
||||
result := fmt.Sprintf("Confirmation for <i>%s</i> on site %s\n"+
|
||||
"Please copy and paste this text into “token” field on comments page to confirm subscription:\n\n\n"+
|
||||
"<pre>%s</pre>",
|
||||
escapeTelegramText(user), escapeTelegramText(site), escapeTelegramText(token))
|
||||
body := telegramMsg{Text: result, ParseMode: "HTML"}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
// TelegramUpdate contains update information, which is used from whole telegram API response
|
||||
type TelegramUpdate struct {
|
||||
Result []struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
Message struct {
|
||||
Chat struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"first_name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// GetBotUsername returns bot username
|
||||
@@ -199,6 +213,110 @@ func (t *Telegram) GetBotUsername() string {
|
||||
return t.username
|
||||
}
|
||||
|
||||
// AddToken adds token
|
||||
func (t *Telegram) AddToken(token, user, site string, expires time.Time) {
|
||||
t.requests.Lock()
|
||||
t.requests.data[token] = tgAuthRequest{
|
||||
expires: expires,
|
||||
user: user,
|
||||
site: site,
|
||||
}
|
||||
t.requests.Unlock()
|
||||
}
|
||||
|
||||
// CheckToken verifies incoming token, returns the user address if it's confirmed and empty string otherwise
|
||||
func (t *Telegram) CheckToken(token, user string) (telegram, site string, err error) {
|
||||
t.requests.RLock()
|
||||
authRequest, ok := t.requests.data[token]
|
||||
t.requests.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return "", "", errors.New("request is not found")
|
||||
}
|
||||
|
||||
if time.Now().After(authRequest.expires) {
|
||||
t.requests.Lock()
|
||||
delete(t.requests.data, token)
|
||||
t.requests.Unlock()
|
||||
return "", "", errors.New("request expired")
|
||||
}
|
||||
|
||||
if !authRequest.confirmed {
|
||||
return "", "", errors.New("request is not verified yet")
|
||||
}
|
||||
|
||||
if authRequest.user != user {
|
||||
return "", "", errors.New("user does not match original requester")
|
||||
}
|
||||
|
||||
// Delete request
|
||||
t.requests.Lock()
|
||||
delete(t.requests.data, token)
|
||||
t.requests.Unlock()
|
||||
|
||||
return authRequest.telegramID, authRequest.site, nil
|
||||
}
|
||||
|
||||
// Run starts processing login requests sent in Telegram, required for user notifications to work
|
||||
// Blocks caller
|
||||
func (t *Telegram) Run(ctx context.Context) {
|
||||
atomic.AddInt32(&t.run, 1)
|
||||
processUpdatedTicker := time.NewTicker(t.apiPollInterval)
|
||||
cleanupTicker := time.NewTicker(t.expiredCleanupInterval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
processUpdatedTicker.Stop()
|
||||
cleanupTicker.Stop()
|
||||
atomic.AddInt32(&t.run, -1)
|
||||
return
|
||||
case <-processUpdatedTicker.C:
|
||||
updates, err := t.getUpdates(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Error while getting telegram updates: %v", err)
|
||||
continue
|
||||
}
|
||||
t.processUpdates(ctx, updates)
|
||||
case <-cleanupTicker.C:
|
||||
now := time.Now()
|
||||
t.requests.Lock()
|
||||
for key, req := range t.requests.data {
|
||||
if now.After(req.expires) {
|
||||
delete(t.requests.data, key)
|
||||
}
|
||||
}
|
||||
t.requests.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessUpdate is alternative to Run, it processes provided plain text update from Telegram
|
||||
// so that caller could get updates and send it not only there but to multiple sources
|
||||
func (t *Telegram) ProcessUpdate(ctx context.Context, textUpdate string) error {
|
||||
if atomic.LoadInt32(&t.run) != 0 {
|
||||
return errors.New("Run goroutine should not be used with ProcessUpdate")
|
||||
}
|
||||
defer func() {
|
||||
// as Run goroutine is not running, clean up old requests on each update
|
||||
// even if we hit json decode error
|
||||
now := time.Now()
|
||||
t.requests.Lock()
|
||||
for key, req := range t.requests.data {
|
||||
if now.After(req.expires) {
|
||||
delete(t.requests.data, key)
|
||||
}
|
||||
}
|
||||
t.requests.Unlock()
|
||||
}()
|
||||
var updates TelegramUpdate
|
||||
if err := json.Unmarshal([]byte(textUpdate), &updates); err != nil {
|
||||
return errors.Wrap(err, "failed to decode provided telegram update")
|
||||
}
|
||||
t.processUpdates(ctx, &updates)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Telegram) String() string {
|
||||
result := "telegram"
|
||||
if t.AdminChannelID != "" {
|
||||
@@ -210,13 +328,81 @@ func (t *Telegram) String() string {
|
||||
return result
|
||||
}
|
||||
|
||||
// getUpdates fetches incoming updates
|
||||
func (t *Telegram) getUpdates(ctx context.Context) (*TelegramUpdate, error) {
|
||||
url := `getUpdates?allowed_updates=["message"]`
|
||||
if t.updateOffset != 0 {
|
||||
url += fmt.Sprintf("&offset=%d", t.updateOffset)
|
||||
}
|
||||
|
||||
var result TelegramUpdate
|
||||
|
||||
err := t.Request(ctx, url, nil, &result)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to fetch updates")
|
||||
}
|
||||
|
||||
for _, u := range result.Result {
|
||||
if u.UpdateID >= t.updateOffset {
|
||||
t.updateOffset = u.UpdateID + 1
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// processUpdates processes a batch of updates from telegram servers
|
||||
func (t *Telegram) processUpdates(ctx context.Context, updates *TelegramUpdate) {
|
||||
for _, update := range updates.Result {
|
||||
if update.Message.Chat.Type != "private" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(update.Message.Text, "/start ") {
|
||||
continue
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(update.Message.Text, "/start ")
|
||||
|
||||
t.requests.RLock()
|
||||
authRequest, ok := t.requests.data[token]
|
||||
if !ok { // No such token
|
||||
t.requests.RUnlock()
|
||||
if t.ErrorMsg != "" {
|
||||
if err := t.sendText(ctx, update.Message.Chat.ID, t.ErrorMsg); err != nil {
|
||||
log.Printf("[WARN] failed to notify telegram peer: %v", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
t.requests.RUnlock()
|
||||
|
||||
authRequest.confirmed = true
|
||||
authRequest.telegramID = strconv.Itoa(update.Message.Chat.ID)
|
||||
|
||||
t.requests.Lock()
|
||||
t.requests.data[token] = authRequest
|
||||
t.requests.Unlock()
|
||||
|
||||
if err := t.sendText(ctx, update.Message.Chat.ID, t.SuccessMsg); err != nil {
|
||||
log.Printf("[ERROR] failed to notify telegram peer: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendText sends a plain text message to telegram peer
|
||||
func (t *Telegram) sendText(ctx context.Context, recipientID int, msg string) error {
|
||||
url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", recipientID, neturl.PathEscape(msg))
|
||||
return t.Request(ctx, url, nil, &struct{}{})
|
||||
}
|
||||
|
||||
// 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)
|
||||
err := t.Request(ctx, "getMe", nil, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -227,7 +413,8 @@ func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) {
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) request(ctx context.Context, method string, b []byte, data interface{}) error {
|
||||
// Request makes a request to the Telegram API and return the result
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -179,28 +180,282 @@ func TestTelegram_SendVerification(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tb)
|
||||
|
||||
// proper VerificationRequest without telegram
|
||||
req := VerificationRequest{
|
||||
SiteID: "remark",
|
||||
User: "test_username",
|
||||
Token: "secret_",
|
||||
}
|
||||
assert.NoError(t, tb.SendVerification(context.TODO(), req))
|
||||
// empty VerificationRequest should return no error and no nothing, as well as any other
|
||||
assert.NoError(t, tb.SendVerification(context.TODO(), VerificationRequest{}))
|
||||
}
|
||||
|
||||
// proper VerificationRequest with telegram
|
||||
req.Telegram = "test"
|
||||
assert.NoError(t, tb.SendVerification(context.TODO(), req))
|
||||
const getUpdatesResp = `{
|
||||
"ok": true,
|
||||
"result": [
|
||||
{
|
||||
"update_id": 998,
|
||||
"message": {
|
||||
"chat": {
|
||||
"type": "group"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"update_id": 999,
|
||||
"message": {
|
||||
"text": "not starting with /start",
|
||||
"chat": {
|
||||
"type": "private"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"update_id": 1000,
|
||||
"message": {
|
||||
"message_id": 4,
|
||||
"from": {
|
||||
"id": 313131313,
|
||||
"is_bot": false,
|
||||
"first_name": "Joe",
|
||||
"username": "joe123",
|
||||
"language_code": "en"
|
||||
},
|
||||
"chat": {
|
||||
"id": 313131313,
|
||||
"first_name": "Joe",
|
||||
"username": "joe123",
|
||||
"type": "private"
|
||||
},
|
||||
"date": 1601665548,
|
||||
"text": "/start token",
|
||||
"entities": [
|
||||
{
|
||||
"offset": 0,
|
||||
"length": 6,
|
||||
"type": "bot_command"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// VerificationRequest with canceled context
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
cancel()
|
||||
assert.EqualError(t, tb.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context")
|
||||
|
||||
// test buildVerificationMessage separately for message text
|
||||
res, err := tb.buildVerificationMessage(req.User, req.Token, req.SiteID)
|
||||
func TestTelegram_GetUpdatesFlow(t *testing.T) {
|
||||
first := true
|
||||
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), "sendMessage") {
|
||||
// respond normally to processUpdates attempt to send message back to user
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
// responses to get updates calls to API
|
||||
if first {
|
||||
assert.Equal(t, "", r.URL.Query().Get("offset"))
|
||||
first = false
|
||||
} else {
|
||||
assert.Equal(t, "1001", r.URL.Query().Get("offset"))
|
||||
}
|
||||
_, _ = w.Write([]byte(getUpdatesResp))
|
||||
})
|
||||
defer ts.Close()
|
||||
tb, err := NewTelegram(TelegramParams{
|
||||
AdminChannelID: "remark_test",
|
||||
Token: "xxxsupersecretxxx",
|
||||
UserNotifications: true,
|
||||
apiPrefix: ts.URL + "/",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(res), `Confirmation for \u003ci\u003etest_username\u003c/i\u003e on site remark`)
|
||||
assert.Contains(t, string(res), `secret_`)
|
||||
|
||||
// send request with no offset
|
||||
upd, err := tb.getUpdates(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, upd.Result, 3)
|
||||
assert.Equal(t, 1001, tb.updateOffset)
|
||||
assert.Equal(t, "/start token", upd.Result[len(upd.Result)-1].Message.Text)
|
||||
|
||||
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
|
||||
_, _, err = tb.CheckToken("token", "user")
|
||||
assert.Error(t, err)
|
||||
tb.processUpdates(context.Background(), upd)
|
||||
tgID, site, err := tb.CheckToken("token", "user")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "313131313", tgID)
|
||||
assert.Equal(t, "site", site)
|
||||
|
||||
// send request with offset
|
||||
_, err = tb.getUpdates(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTelegram_ProcessUpdateFlow(t *testing.T) {
|
||||
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
// respond normally to processUpdates attempt to send message back to user
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
})
|
||||
defer ts.Close()
|
||||
tb, err := NewTelegram(TelegramParams{
|
||||
AdminChannelID: "remark_test",
|
||||
Token: "xxxsupersecretxxx",
|
||||
UserNotifications: true,
|
||||
apiPrefix: ts.URL + "/",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
|
||||
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
|
||||
assert.Len(t, tb.requests.data, 2)
|
||||
_, _, err = tb.CheckToken("token", "user")
|
||||
assert.Error(t, err)
|
||||
assert.NoError(t, tb.ProcessUpdate(context.Background(), getUpdatesResp))
|
||||
assert.Len(t, tb.requests.data, 1, "expired token was cleaned up")
|
||||
tgID, site, err := tb.CheckToken("token", "user")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, tb.requests.data, 0, "token is deleted after successful check")
|
||||
assert.Equal(t, "313131313", tgID)
|
||||
assert.Equal(t, "site", site)
|
||||
|
||||
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
|
||||
assert.Len(t, tb.requests.data, 1)
|
||||
assert.EqualError(t, tb.ProcessUpdate(context.Background(), ""), "failed to decode provided telegram update: unexpected end of JSON input")
|
||||
assert.Len(t, tb.requests.data, 0, "expired token should be cleaned up despite the error")
|
||||
}
|
||||
|
||||
const sendMessageResp = `{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 100,
|
||||
"from": {
|
||||
"id": 666666666,
|
||||
"is_bot": true,
|
||||
"first_name": "Test auth bot",
|
||||
"username": "TestAuthBot"
|
||||
},
|
||||
"chat": {
|
||||
"id": 313131313,
|
||||
"first_name": "Joe",
|
||||
"username": "joe123",
|
||||
"type": "private"
|
||||
},
|
||||
"date": 1602430546,
|
||||
"text": "123"
|
||||
}
|
||||
}`
|
||||
|
||||
func TestTelegram_SendText(t *testing.T) {
|
||||
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "123", r.URL.Query().Get("chat_id"))
|
||||
assert.Equal(t, "hello there", r.URL.Query().Get("text"))
|
||||
_, _ = w.Write([]byte(sendMessageResp))
|
||||
})
|
||||
defer ts.Close()
|
||||
tb, err := NewTelegram(TelegramParams{
|
||||
AdminChannelID: "remark_test",
|
||||
Token: "xxxsupersecretxxx",
|
||||
UserNotifications: true,
|
||||
apiPrefix: ts.URL + "/",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = tb.sendText(context.Background(), 123, "hello there")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
const errorResp = `{"ok":false,"error_code":400,"description":"Very bad request"}`
|
||||
|
||||
func TestTelegram_Error(t *testing.T) {
|
||||
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(errorResp))
|
||||
})
|
||||
defer ts.Close()
|
||||
tb, err := NewTelegram(TelegramParams{
|
||||
AdminChannelID: "remark_test",
|
||||
Token: "xxxsupersecretxxx",
|
||||
UserNotifications: true,
|
||||
apiPrefix: ts.URL + "/",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = tb.getUpdates(context.Background())
|
||||
assert.EqualError(t, err, "failed to fetch updates: unexpected telegram API status code 400, error: \"Very bad request\"")
|
||||
}
|
||||
|
||||
func TestTelegram_TokenVerification(t *testing.T) {
|
||||
ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), "sendMessage") {
|
||||
// respond normally to processUpdates attempt to send message back to user
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
// responses to get updates calls to API
|
||||
_, _ = w.Write([]byte(getUpdatesResp))
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
tb, err := NewTelegram(TelegramParams{
|
||||
AdminChannelID: "remark_test",
|
||||
Token: "good-token",
|
||||
apiPrefix: ts.URL + "/",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tb)
|
||||
tb.AddToken("token", "user", "site", time.Now().Add(time.Minute))
|
||||
assert.Len(t, tb.requests.data, 1)
|
||||
|
||||
// wrong token
|
||||
tgID, site, err := tb.CheckToken("unknown token", "user")
|
||||
assert.Empty(t, tgID)
|
||||
assert.Empty(t, site)
|
||||
assert.EqualError(t, err, "request is not found")
|
||||
|
||||
// right token and user, not verified yet
|
||||
tgID, site, err = tb.CheckToken("token", "user")
|
||||
assert.Empty(t, tgID)
|
||||
assert.Empty(t, site)
|
||||
assert.EqualError(t, err, "request is not verified yet")
|
||||
|
||||
// confirm request
|
||||
authRequest, ok := tb.requests.data["token"]
|
||||
assert.True(t, ok)
|
||||
authRequest.confirmed = true
|
||||
authRequest.telegramID = "telegramID"
|
||||
tb.requests.data["token"] = authRequest
|
||||
|
||||
// wrong user
|
||||
tgID, site, err = tb.CheckToken("token", "wrong user")
|
||||
assert.Empty(t, tgID)
|
||||
assert.Empty(t, site)
|
||||
assert.EqualError(t, err, "user does not match original requester")
|
||||
|
||||
// successful check
|
||||
tgID, site, err = tb.CheckToken("token", "user")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "telegramID", tgID)
|
||||
assert.Equal(t, "site", site)
|
||||
|
||||
// expired token
|
||||
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
|
||||
tgID, site, err = tb.CheckToken("expired token", "user")
|
||||
assert.Empty(t, tgID)
|
||||
assert.Empty(t, site)
|
||||
assert.EqualError(t, err, "request expired")
|
||||
assert.Len(t, tb.requests.data, 0)
|
||||
|
||||
// expired token, cleaned up by the cleanup
|
||||
tb.apiPollInterval = time.Millisecond * 15
|
||||
tb.expiredCleanupInterval = time.Millisecond * 10
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go tb.Run(ctx)
|
||||
assert.Eventually(t, func() bool {
|
||||
return tb.ProcessUpdate(ctx, "").Error() == "Run goroutine should not be used with ProcessUpdate"
|
||||
}, time.Millisecond*100, time.Millisecond*10, "ProcessUpdate should not work same time as Run")
|
||||
tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute))
|
||||
tb.requests.RLock()
|
||||
assert.Len(t, tb.requests.data, 1)
|
||||
tb.requests.RUnlock()
|
||||
time.Sleep(tb.expiredCleanupInterval * 2)
|
||||
tb.requests.RLock()
|
||||
assert.Len(t, tb.requests.data, 0)
|
||||
tb.requests.RUnlock()
|
||||
cancel()
|
||||
// give enough time for Run() to finish
|
||||
time.Sleep(tb.expiredCleanupInterval)
|
||||
}
|
||||
|
||||
const getMeResp = `{"ok": true,
|
||||
@@ -211,7 +466,16 @@ const getMeResp = `{"ok": true,
|
||||
"username": "remark42_test_bot"
|
||||
}}`
|
||||
|
||||
func mockTelegramServer(_ http.HandlerFunc) *httptest.Server {
|
||||
func mockTelegramServer(h http.HandlerFunc) *httptest.Server {
|
||||
if h != nil {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), "getMe") {
|
||||
_, _ = w.Write([]byte(getMeResp))
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}))
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(getMeResp))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package providers
|
||||
|
||||
// Both Telegram auth and notifications need to receive messages received by Telegram bot in the loop,
|
||||
// and below is the implementation of such loop which dispatched received events to both receivers,
|
||||
// so that they could work at the same time.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
)
|
||||
|
||||
type tgRequester interface {
|
||||
Request(ctx context.Context, method string, b []byte, data interface{}) error
|
||||
}
|
||||
|
||||
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
|
||||
type TGUpdatesReceiver interface {
|
||||
fmt.Stringer
|
||||
ProcessUpdate(ctx context.Context, textUpdate string) error
|
||||
}
|
||||
|
||||
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
|
||||
// Blocks caller
|
||||
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
|
||||
// Identifier of the first update to be requested.
|
||||
// Should be equal to LastSeenUpdateID + 1
|
||||
// See https://core.telegram.org/bots/api#getupdates
|
||||
var updateOffset int
|
||||
|
||||
processUpdatedTicker := time.NewTicker(period)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
processUpdatedTicker.Stop()
|
||||
return
|
||||
case <-processUpdatedTicker.C:
|
||||
url := `getUpdates?allowed_updates=["message"]`
|
||||
if updateOffset != 0 {
|
||||
url += fmt.Sprintf("&offset=%d", updateOffset)
|
||||
}
|
||||
|
||||
var update notify.TelegramUpdate
|
||||
|
||||
err := requester.Request(ctx, url, nil, &update)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to fetch updates: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, u := range update.Result {
|
||||
if u.UpdateID >= updateOffset {
|
||||
updateOffset = u.UpdateID + 1
|
||||
}
|
||||
}
|
||||
|
||||
if raw, err := json.Marshal(update); err == nil {
|
||||
for _, r := range receivers {
|
||||
e := r.ProcessUpdate(ctx, string(raw))
|
||||
if e != nil {
|
||||
log.Printf("[ERROR] failure from destination %s on processing telegram update %v", r, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
)
|
||||
|
||||
func TestDispatchTelegramUpdates(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
poolPeriod := time.Millisecond * 100
|
||||
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
||||
time.Sleep(poolPeriod * 3)
|
||||
cancel()
|
||||
time.Sleep(poolPeriod)
|
||||
}
|
||||
|
||||
const getUpdatesResp = `{
|
||||
"ok": true,
|
||||
"result": [
|
||||
{
|
||||
"update_id": 998,
|
||||
"message": {
|
||||
"chat": {
|
||||
"type": "group"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
type mockTGRequester struct {
|
||||
hit int
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error {
|
||||
if m.hit < 2 {
|
||||
m.hit++
|
||||
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
|
||||
return nil
|
||||
}
|
||||
return errors.New("test error")
|
||||
}
|
||||
|
||||
type mockTGUpdatesReceiver struct {
|
||||
t *testing.T
|
||||
hit int
|
||||
}
|
||||
|
||||
func (m *mockTGUpdatesReceiver) String() string {
|
||||
return "mock updater"
|
||||
}
|
||||
|
||||
func (m *mockTGUpdatesReceiver) ProcessUpdate(_ context.Context, textUpdate string) error {
|
||||
var result notify.TelegramUpdate
|
||||
err := json.Unmarshal([]byte(textUpdate), &result)
|
||||
assert.NoError(m.t, err)
|
||||
if m.hit < 2 {
|
||||
assert.NotNil(m.t, result.Result)
|
||||
m.hit++
|
||||
return nil
|
||||
}
|
||||
assert.Nil(m.t, result.Result)
|
||||
return errors.New("test error")
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type Rest struct {
|
||||
CommentFormatter *store.CommentFormatter
|
||||
Migrator *Migrator
|
||||
NotifyService *notify.Service
|
||||
TelegramService telegramService
|
||||
ImageService *image.Service
|
||||
|
||||
AnonVote bool
|
||||
@@ -324,8 +325,7 @@ func (s *Rest) routes() chi.Router {
|
||||
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/telegram/subscribe", s.privRest.sendTelegramConfirmationCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/telegram/confirm", s.privRest.setConfirmedTelegramCtrl)
|
||||
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
|
||||
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
|
||||
})
|
||||
|
||||
@@ -374,6 +374,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
|
||||
readOnlyAge: s.ReadOnlyAge,
|
||||
authenticator: s.Authenticator,
|
||||
notifyService: s.NotifyService,
|
||||
telegramService: s.TelegramService,
|
||||
remarkURL: s.RemarkURL,
|
||||
anonVote: s.AnonVote,
|
||||
templates: templates.NewFS(),
|
||||
|
||||
@@ -3,8 +3,9 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //nolint:gosec //not used for security
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
@@ -39,11 +41,19 @@ type private struct {
|
||||
imageService *image.Service
|
||||
notifyService *notify.Service
|
||||
authenticator *auth.Service
|
||||
telegramService telegramService
|
||||
remarkURL string
|
||||
anonVote bool
|
||||
templates templates.FileReader
|
||||
}
|
||||
|
||||
// telegramService is a subset of Telegram service used for setting up user telegram notifications
|
||||
type telegramService interface {
|
||||
AddToken(token, user, site string, expires time.Time)
|
||||
CheckToken(token, userID string) (telegram, site string, err error)
|
||||
GetBotUsername() string
|
||||
}
|
||||
|
||||
type privStore interface {
|
||||
Create(comment store.Comment) (commentID string, err error)
|
||||
EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error)
|
||||
@@ -349,55 +359,64 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
}
|
||||
|
||||
// sendTelegramConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
||||
// GET /telegram/subscribe?site=siteID&address=@someone
|
||||
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
|
||||
func (s *private) sendTelegramConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
// telegramSubscribeCtrl generates and verifies telegram notification request
|
||||
// GET /telegram/subscribe?site=siteID<&tkn=token>
|
||||
func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
address := r.URL.Query().Get("address")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
if address == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest,
|
||||
errors.New("missing parameter"), "address parameter is required", rest.ErrInternal)
|
||||
|
||||
if s.telegramService == nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError,
|
||||
errors.New("not enabled"), "telegram notifications are not enabled", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
existingAddress, err := s.dataService.GetUserTelegram(siteID, user.ID)
|
||||
|
||||
queryToken := r.URL.Query().Get("tkn")
|
||||
if queryToken == "" {
|
||||
// GET /telegram/subscribe?site=siteID (No token supplied)
|
||||
siteID := r.URL.Query().Get("site")
|
||||
if siteID == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "site parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
// we don't care as much if we can't retrieve the current value of that field for the user, so ignore the error
|
||||
if existingAddress, _ := s.dataService.GetUserTelegram(siteID, user.ID); existingAddress != "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict,
|
||||
errors.New("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
// Generate and send token
|
||||
tkn, err := randToken()
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
expires := time.Now().Add(10 * time.Minute)
|
||||
|
||||
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
|
||||
|
||||
render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GET /telegram/subscribe?tkn=token (verify token)
|
||||
var address, siteID string
|
||||
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
|
||||
if err != nil {
|
||||
// we don't care as much if we can't retrieve the current value of that field for the user,
|
||||
// as it's only used to check if we're trying to set to the same value it's already set to
|
||||
log.Printf("[WARN] can't read telegram for %s, %v", user.ID, err)
|
||||
}
|
||||
if address == existingAddress {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict,
|
||||
errors.New("already verified"), "telegram address is already verified for this user", rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: user.ID + "::" + address},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: r.URL.Query().Get("site"),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
|
||||
tkn, err := s.authenticator.TokenService().Token(claims)
|
||||
log.Printf("[DEBUG] set telegram notifications for user %s", user.ID)
|
||||
|
||||
val, err := s.dataService.SetUserTelegram(siteID, user.ID, address)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal)
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code)
|
||||
return
|
||||
}
|
||||
|
||||
s.notifyService.SubmitVerification(
|
||||
notify.VerificationRequest{
|
||||
SiteID: siteID,
|
||||
User: user.Name,
|
||||
Telegram: address,
|
||||
Token: tkn,
|
||||
},
|
||||
)
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
||||
@@ -453,47 +472,6 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// setConfirmedTelegramCtrl uses provided token parameter (generated by sendTelegramConfirmationCtrl) to set telegram and add it to user token
|
||||
// PUT /telegram/confirm?site=siteID&tkn=jwt
|
||||
func (s *private) setConfirmedTelegramCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tkn := r.URL.Query().Get("tkn")
|
||||
if tkn == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
user := rest.MustGetUserInfo(r)
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
if s.authenticator.TokenService().IsExpired(confClaims) {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake.ID is user.ID + "::" + address
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 || elems[0] != user.ID {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
address := elems[1]
|
||||
|
||||
log.Printf("[DEBUG] set telegram for user %s", user.ID)
|
||||
|
||||
val, err := s.dataService.SetUserTelegram(siteID, user.ID, address)
|
||||
if err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
||||
func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tkn := r.URL.Query().Get("tkn")
|
||||
@@ -748,3 +726,15 @@ func (s *private) isReadOnly(locator store.Locator) bool {
|
||||
}
|
||||
return s.dataService.IsReadOnly(locator) // ro manually
|
||||
}
|
||||
|
||||
func randToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", errors.Wrap(err, "can't get random")
|
||||
}
|
||||
s := sha1.New() //nolint:gosec // not used for security
|
||||
if _, err := s.Write(b); err != nil {
|
||||
return "", errors.Wrap(err, "can't write randoms to sha1")
|
||||
}
|
||||
return fmt.Sprintf("%x", s.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
@@ -578,6 +579,7 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
srv.privRest.templates = &MockFS{}
|
||||
srv.privRest.telegramService = &mockTelegram{site: "remark42"}
|
||||
|
||||
// issue good token
|
||||
claims := token.Claims{
|
||||
@@ -620,14 +622,13 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
{description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
||||
{description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token not set", url: "/api/v1/telegram/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send confirmation without address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send confirmation", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token is good", url: fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
||||
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusBadRequest},
|
||||
{description: "send telegram confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict},
|
||||
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token is good", url: fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
}
|
||||
client := http.Client{}
|
||||
for _, x := range testData {
|
||||
@@ -852,8 +853,8 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.Equal(t, 2, len(mockDestination.Get()))
|
||||
assert.Empty(t, mockDestination.Get()[1].Telegrams)
|
||||
|
||||
// send confirmation token for telegram
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/telegram/subscribe?site=remark42&address=good_telegram", nil)
|
||||
// subscribe to telegram while the telegram destination is absent
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?site=remark42", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
@@ -861,15 +862,13 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.GetVerify()))
|
||||
assert.Equal(t, "good_telegram", mockDestination.GetVerify()[0].Telegram)
|
||||
verificationToken := mockDestination.GetVerify()[0].Token
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body))
|
||||
assert.Equal(t, `{"code":17,"details":"telegram notifications are not enabled","error":"not enabled"}`+"\n", string(body))
|
||||
|
||||
// verify telegram
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", verificationToken), nil)
|
||||
mockTlgrm := &mockTelegram{notVerified: true, site: "unknown_site"}
|
||||
srv.privRest.telegramService = mockTlgrm
|
||||
// send confirmation token for telegram
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?site=remark42", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
@@ -878,6 +877,58 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var subscribeRequest struct {
|
||||
Bot string `json:"bot"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
err = json.Unmarshal(body, &subscribeRequest)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "botUsername", subscribeRequest.Bot)
|
||||
|
||||
// verify telegram, unsuccessfully because of not verified
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body))
|
||||
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"not verified"}`+"\n", string(body))
|
||||
|
||||
mockTlgrm.notVerified = false
|
||||
|
||||
// verify telegram, unsuccessfully because of unknown site
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode, string(body))
|
||||
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"site \"unknown_site\" not found"}`+"\n", string(body))
|
||||
|
||||
mockTlgrm.site = "remark42"
|
||||
// verify telegram, successfully
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var subscribeResult struct {
|
||||
Address string `json:"address"`
|
||||
Updated bool `json:"updated"`
|
||||
}
|
||||
err = json.Unmarshal(body, &subscribeResult)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, subscribeResult.Updated)
|
||||
|
||||
// get user information to verify the subscription
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", nil)
|
||||
@@ -1224,3 +1275,21 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
||||
assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i)
|
||||
}
|
||||
}
|
||||
|
||||
type mockTelegram struct {
|
||||
notVerified bool
|
||||
site string
|
||||
}
|
||||
|
||||
func (m *mockTelegram) AddToken(string, string, string, time.Time) {}
|
||||
|
||||
func (m *mockTelegram) GetBotUsername() string {
|
||||
return "botUsername"
|
||||
}
|
||||
|
||||
func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err error) {
|
||||
if m.notVerified {
|
||||
return "", "", errors.New("not verified")
|
||||
}
|
||||
return "good_telegram", m.site, nil
|
||||
}
|
||||
|
||||
+5
-4
@@ -134,12 +134,13 @@ X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYw
|
||||
DELETE {{host}}/api/v1/email?site={{site}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### send confirmation token for current user to specified telegram. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/telegram/subscribe?site={{site}}&address={{telegram}}
|
||||
### get information for sending confirmation token for current user. auth token for dev user for secret=12345.
|
||||
### After you'll get the response, construct link with it and open it: https://t.me/<bot>?start=<token>
|
||||
GET {{host}}/api/v1/telegram/subscribe?site={{site}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### add telegram for notifications for current user via token from telegram. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/telegram/confirm?site={{site}}&tkn={{token}}
|
||||
### verify telegram notifications for current user via token obtained in the previous step, after talking to bot. auth token for dev user for secret=12345.
|
||||
GET {{host}}/api/v1/telegram/subscribe?tkn={{token}}
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
|
||||
### delete current user telegram. auth token for dev user for secret=12345.
|
||||
|
||||
Reference in New Issue
Block a user