From 0a9ae46d19d0cdaf5aa29649482c8eb9c6aa9f3c Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Tue, 23 Mar 2021 18:12:44 +0100 Subject: [PATCH] Add Telegram Messenger notifications Signed-off-by: Julien Riou --- Makefile | 3 + README.md | 45 +++++++ config.go | 13 ++ go.mod | 1 + go.sum | 8 ++ main.go | 7 ++ notifier.go | 19 ++- notifier_telegram.go | 129 ++++++++++++++++++++ notifier_test.go | 30 +++++ twitter.go => notifier_twitter.go | 15 +-- twitter_test.go => notifier_twitter_test.go | 24 ---- 11 files changed, 255 insertions(+), 39 deletions(-) create mode 100644 notifier_telegram.go create mode 100644 notifier_test.go rename twitter.go => notifier_twitter.go (92%) rename twitter_test.go => notifier_twitter_test.go (84%) diff --git a/Makefile b/Makefile index 1abd79e..74a6fdc 100644 --- a/Makefile +++ b/Makefile @@ -13,5 +13,8 @@ build: release: go build -ldflags "${LDFLAGS}" -o bin/restockbot-${APPVERSION}-${PLATFORM}-${ARCH} *.go +test: + go test + clean: rm -rf bin \ No newline at end of file diff --git a/README.md b/README.md index 1af26a3..368528b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,47 @@ Follow [this procedure](https://github.com/jouir/twitter-login) to generate all * `access_token` * `access_token_secret` +### Telegram (optional) + +Follow [this procedure](https://core.telegram.org/bots#3-how-do-i-create-a-bot) to create a bot `token`. + +Then you have two possible destinations to send messages: +* channel using a `channel_name` (string) +* chat using a `chat_id` (integer) + +For testing purpose, you should store the token in a variable for next sections: +``` +read -s TOKEN +``` + +#### Chat + +To get the chat identifier, you can send a message to your bot then read messages using the API: + +``` +curl -s -XGET "https://api.telegram.org/bot${TOKEN}/getUpdates" | jq -r ".result[].message.chat.id" +``` + +You can test to send messages to a chat with: + +``` +read CHAT_ID +curl -s -XGET "https://api.telegram.org/bot${TOKEN}/sendMessage?chat_id=${CHAT_ID}&text=hello" | jq +``` + +#### Channel + +Public channel names can be used (example: `@mychannel`). For private channels, you should use a `chat_id` instead. + +You can test to send messages to a channel with: + +``` +read CHANNEL_NAME +curl -s -XGET "https://api.telegram.org/bot${TOKEN}/sendMessage?chat_id=${CHANNEL_NAME}&text=hello" | jq +``` + +Don't forget to prefix the channel name with an `@`. + ## Compilation ### With pre-built binaries @@ -74,6 +115,10 @@ Options: * `access_token`: authentication token generated for your Twitter account * `access_token_secret`: authentication token secret generated for your Twitter account * `hashtags`: list of key/value used to append hashtags to each tweet. Key is the pattern to match in the product name, value is the string to append to the tweet. For example, `{"twitter": {"hashtags": [{"rtx 3090": "#nvidia #rtx3090"}]}}` will detect `rtx 3090` to append `#nvidia #rtx3090` at the end of the tweet. +* `telegram` (optional): + * `channel_name`: send message to a channel (ex: `@channel`) + * `chat_id`: send message to a chat (ex: `1234`) + * `token`: key returned by BotFather * `include_regex` (optional): include products with a name matching this regexp * `exclude_regex` (optional): exclude products with a name matching this regexp * `browser_address` (optional): set headless browser address (ex: `http://127.0.0.1:9222`) diff --git a/config.go b/config.go index 9ac5505..d61b8b4 100644 --- a/config.go +++ b/config.go @@ -9,6 +9,7 @@ import ( // Config to store JSON configuration type Config struct { TwitterConfig `json:"twitter"` + TelegramConfig `json:"telegram"` URLs []string `json:"urls"` IncludeRegex string `json:"include_regex"` ExcludeRegex string `json:"exclude_regex"` @@ -24,6 +25,13 @@ type TwitterConfig struct { Hashtags []map[string]string `json:"hashtags"` } +// TelegramConfig to store Telegram API key +type TelegramConfig struct { + Token string `json:"token"` + ChatID int64 `json:"chat_id"` + ChannelName string `json:"channel_name"` +} + // NewConfig creates a Config struct func NewConfig() *Config { return &Config{} @@ -52,3 +60,8 @@ func (c *Config) Read(file string) error { func (c *Config) HasTwitter() bool { return (c.TwitterConfig.AccessToken != "" && c.TwitterConfig.AccessTokenSecret != "" && c.TwitterConfig.ConsumerKey != "" && c.TwitterConfig.ConsumerSecret != "") } + +// HasTelegram returns true when Telegram has been configured +func (c *Config) HasTelegram() bool { + return c.TelegramConfig.Token != "" && (c.TelegramConfig.ChatID != 0 || c.TelegramConfig.ChannelName != "") +} diff --git a/go.mod b/go.mod index f99a0da..c7c1b8a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/MontFerret/ferret v0.13.0 github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d github.com/dghubble/oauth1 v0.7.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1 github.com/sirupsen/logrus v1.8.0 gorm.io/driver/sqlite v1.1.4 gorm.io/gorm v1.20.12 diff --git a/go.sum b/go.sum index 1ad0899..c649475 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/dghubble/oauth1 v0.7.0 h1:AlpZdbRiJM4XGHIlQ8BuJ/wlpGwFEJNnB4Mc+78tA/w github.com/dghubble/oauth1 v0.7.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1 h1:Mr8jIV7wDfLw5Fw6BPupm0aduTFdLjhI3wFuIIZKvO4= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1/go.mod h1:2s/IzRcxCszyNh760IjJiqoYHTnifk8ZeNYL33z8Pww= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= @@ -59,6 +61,7 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mafredri/cdp v0.30.0 h1:Lvcwjajq6wB6Uk8dYeCLrF26LG85rUdpMxgrwdEvU0o= github.com/mafredri/cdp v0.30.0/go.mod h1:71D84qPmWUvBWYj24Zp+U69mrUof4o8qL2X1fQJ/lHc= @@ -76,6 +79,7 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -100,6 +104,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/wI2L/jettison v0.7.1 h1:XNq/WvSOAiJhFww9F5JZZcBZtKFL2Y/9WHHEHLDq9TE= github.com/wI2L/jettison v0.7.1/go.mod h1:dj49nOP41M7x6Jql62BqqF/+nW+XJgBaWzJR0hd6M84= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -134,8 +140,10 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200601175630-2caf76543d99/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index f420b83..c4b58e0 100644 --- a/main.go +++ b/main.go @@ -125,6 +125,13 @@ func main() { } notifiers = append(notifiers, twitterNotifier) } + if config.HasTelegram() { + telegramNotifier, err := NewTelegramNotifier(&config.TelegramConfig, db) + if err != nil { + log.Fatalf("cannot create telegram client: %s", err) + } + notifiers = append(notifiers, telegramNotifier) + } } // Group links by shop diff --git a/notifier.go b/notifier.go index 6dddedf..faafdb2 100644 --- a/notifier.go +++ b/notifier.go @@ -1,9 +1,26 @@ package main -import "time" +import ( + "fmt" + "time" +) // Notifier interface to notify when a product becomes available or is sold out again type Notifier interface { NotifyWhenAvailable(string, string, float64, string, string) error NotifyWhenNotAvailable(string, time.Duration) error } + +// formatPrice using internationalization rules +// euro sign is placed after the value +// default the currency, or symbol if applicable, is placed before the value +func formatPrice(value float64, currency string) string { + switch { + case currency == "EUR": + return fmt.Sprintf("%.2f€", value) + case currency == "USD": + return fmt.Sprintf("$%.2f", value) + default: + return fmt.Sprintf("%s%.2f", currency, value) + } +} diff --git a/notifier_telegram.go b/notifier_telegram.go new file mode 100644 index 0000000..d521170 --- /dev/null +++ b/notifier_telegram.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "time" + + telegram "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// TelegramMessage to store relationship between a Product and a Telegram notification +type TelegramMessage struct { + gorm.Model + MessageID int `gorm:"not null;unique"` + ProductURL string + Product Product `gorm:"foreignKey:ProductURL"` +} + +// TelegramNotifier to manage notifications to Twitter +type TelegramNotifier struct { + db *gorm.DB + bot *telegram.BotAPI + chatID int64 + channelName string + timeout int +} + +// NewTelegramNotifier to create a Notifier with Telegram capabilities +func NewTelegramNotifier(config *TelegramConfig, db *gorm.DB) (*TelegramNotifier, error) { + // create table + err := db.AutoMigrate(&TelegramMessage{}) + if err != nil { + return nil, err + } + + // create client + bot, err := telegram.NewBotAPI(config.Token) + if err != nil { + return nil, err + } + log.Debugf("connected to telegram as %s", bot.Self.UserName) + + return &TelegramNotifier{ + db: db, + bot: bot, + chatID: config.ChatID, + channelName: config.ChannelName, + }, nil +} + +// NotifyWhenAvailable create a Telegram message for announcing that a product is available +// implements the Notifier interface +func (n *TelegramNotifier) NotifyWhenAvailable(shopName string, productName string, productPrice float64, productCurrency string, productURL string) error { + // TODO: check if message exists in the database to avoid flood + + // send message to telegram + formattedPrice := formatPrice(productPrice, productCurrency) + message := fmt.Sprintf("🟢 %s: %s for %s is available at %s", shopName, productName, formattedPrice, productURL) + messageID, err := n.sendMessage(message, 0) + if err != nil { + return err + } + + // save telegram message to database + m := TelegramMessage{MessageID: messageID, ProductURL: productURL} + trx := n.db.Create(&m) + if trx.Error != nil { + return fmt.Errorf("failed to save telegram message %d to database: %s", m.MessageID, trx.Error) + } + log.Debugf("telegram message %d saved to database", m.MessageID) + + return nil +} + +// NotifyWhenNotAvailable create a Telegram message replying to the NotifyWhenAvailable message to say it's gone +// implements the Notifier interface +func (n *TelegramNotifier) NotifyWhenNotAvailable(productURL string, duration time.Duration) error { + // find message in the database + var m TelegramMessage + trx := n.db.Where(TelegramMessage{ProductURL: productURL}).First(&m) + if trx.Error != nil { + return fmt.Errorf("failed to find telegram message in database for product with url %s: %s", productURL, trx.Error) + } + if m.MessageID == 0 { + log.Warnf("telegram message for product with url %s not found, skipping close notification", productURL) + return nil + } + + // format message + text := fmt.Sprintf("🔴 And it's gone (%s)", duration) + + // send reply on telegram + _, err := n.sendMessage(text, m.MessageID) + if err != nil { + return fmt.Errorf("failed to reply on telegram: %s", err) + } + log.Infof("reply to telegram message %d sent", m.MessageID) + + // remove message from database + trx = n.db.Unscoped().Delete(&m) + if trx.Error != nil { + return fmt.Errorf("failed to remove message %d from database: %s", m.MessageID, trx.Error) + } + log.Debugf("telegram message removed from database") + return nil +} + +func (n *TelegramNotifier) sendMessage(text string, reply int) (int, error) { + log.Debugf("sending message %s to telegram", text) + var request telegram.MessageConfig + if n.chatID != 0 { + request = telegram.NewMessage(n.chatID, text) + } else { + request = telegram.NewMessageToChannel(n.channelName, text) + } + request.DisableWebPagePreview = true + + if reply != 0 { + request.ReplyToMessageID = reply + } + + response, err := n.bot.Send(request) + if err != nil { + return 0, err + } + log.Infof("message %d sent to telegram", response.MessageID) + return response.MessageID, nil +} diff --git a/notifier_test.go b/notifier_test.go new file mode 100644 index 0000000..46909e8 --- /dev/null +++ b/notifier_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "testing" +) + +func TestFormatPrice(t *testing.T) { + tests := []struct { + value float64 + currency string + expected string + }{ + {999.99, "EUR", "999.99€"}, + {999.99, "USD", "$999.99"}, + {999.99, "CHF", "CHF999.99"}, + {999.99, "", "999.99"}, + } + + for i, tc := range tests { + t.Run(fmt.Sprintf("TestFormatPrice#%d", i), func(t *testing.T) { + got := formatPrice(tc.value, tc.currency) + if got != tc.expected { + t.Errorf("for value %0.2f and currency %s, got %s, want %s", tc.value, tc.currency, got, tc.expected) + } else { + t.Logf("for value %0.2f and currency %s, got %s, want %s", tc.value, tc.currency, got, tc.expected) + } + }) + } +} diff --git a/twitter.go b/notifier_twitter.go similarity index 92% rename from twitter.go rename to notifier_twitter.go index 563228b..161bbd4 100644 --- a/twitter.go +++ b/notifier_twitter.go @@ -94,23 +94,10 @@ func (c *TwitterNotifier) buildHashtags(productName string) string { return "" } -// formatPrice using internationalization rules -// euro sign is placed after the value -// default the currency, or symbol if applicable, is placed before the value -func formatPrice(value float64, currency string) string { - switch { - case currency == "EUR": - return fmt.Sprintf("%.2f€", value) - case currency == "USD": - return fmt.Sprintf("$%.2f", value) - default: - return fmt.Sprintf("%s%.2f", currency, value) - } -} - // NotifyWhenAvailable create a Twitter status for announcing that a product is available // implements the Notifier interface func (c *TwitterNotifier) NotifyWhenAvailable(shopName string, productName string, productPrice float64, productCurrency string, productURL string) error { + // TODO: check if message exists in the database to avoid flood hashtags := c.buildHashtags(productName) message := formatAvailableTweet(shopName, productName, productPrice, productCurrency, productURL, hashtags) // create thread diff --git a/twitter_test.go b/notifier_twitter_test.go similarity index 84% rename from twitter_test.go rename to notifier_twitter_test.go index 2d209c6..4ca8411 100644 --- a/twitter_test.go +++ b/notifier_twitter_test.go @@ -56,30 +56,6 @@ func TestBuildHashtags(t *testing.T) { } } -func TestFormatPrice(t *testing.T) { - tests := []struct { - value float64 - currency string - expected string - }{ - {999.99, "EUR", "999.99€"}, - {999.99, "USD", "$999.99"}, - {999.99, "CHF", "CHF999.99"}, - {999.99, "", "999.99"}, - } - - for i, tc := range tests { - t.Run(fmt.Sprintf("TestFormatPrice#%d", i), func(t *testing.T) { - got := formatPrice(tc.value, tc.currency) - if got != tc.expected { - t.Errorf("for value %0.2f and currency %s, got %s, want %s", tc.value, tc.currency, got, tc.expected) - } else { - t.Logf("for value %0.2f and currency %s, got %s, want %s", tc.value, tc.currency, got, tc.expected) - } - }) - } -} - func TestFormatAvailableTweet(t *testing.T) { tests := []struct { shopName string