Create new thread when product is available again (#28)
Instead of spamming a twitter thread with generic replies confusing the community because the original message was posted long time ago, the bot now creates a new thread with all product information and an incrementing counter for uniqueness.
This commit is contained in:
parent
c796cb20f2
commit
d6ee2922d7
2 changed files with 76 additions and 24 deletions
|
@ -23,6 +23,7 @@ type Tweet struct {
|
||||||
TweetID int64 `gorm:"not null;unique"`
|
TweetID int64 `gorm:"not null;unique"`
|
||||||
Hash string `gorm:"unique"`
|
Hash string `gorm:"unique"`
|
||||||
LastTweetID int64 `gorm:"index"`
|
LastTweetID int64 `gorm:"index"`
|
||||||
|
Counter int64 `gorm:"not null;default:1"`
|
||||||
ProductURL string `gorm:"index"`
|
ProductURL string `gorm:"index"`
|
||||||
Product Product `gorm:"not null;references:URL"`
|
Product Product `gorm:"not null;references:URL"`
|
||||||
}
|
}
|
||||||
|
@ -143,7 +144,7 @@ func (c *TwitterNotifier) buildHashtags(productName string) string {
|
||||||
func (c *TwitterNotifier) NotifyWhenAvailable(shopName string, productName string, productPrice float64, productCurrency string, productURL string) error {
|
func (c *TwitterNotifier) NotifyWhenAvailable(shopName string, productName string, productPrice float64, productCurrency string, productURL string) error {
|
||||||
// format message
|
// format message
|
||||||
hashtags := c.buildHashtags(productName)
|
hashtags := c.buildHashtags(productName)
|
||||||
message := formatAvailableTweet(shopName, productName, productPrice, productCurrency, productURL, hashtags)
|
message := formatAvailableTweet(shopName, productName, productPrice, productCurrency, productURL, hashtags, 0)
|
||||||
|
|
||||||
// compute message checksum to avoid duplicates
|
// compute message checksum to avoid duplicates
|
||||||
var tweet Tweet
|
var tweet Tweet
|
||||||
|
@ -164,7 +165,7 @@ func (c *TwitterNotifier) NotifyWhenAvailable(shopName string, productName strin
|
||||||
log.Infof("tweet %d sent for product '%s'", tweetID, productURL)
|
log.Infof("tweet %d sent for product '%s'", tweetID, productURL)
|
||||||
|
|
||||||
// save thread to database
|
// save thread to database
|
||||||
tweet = Tweet{TweetID: tweetID, ProductURL: productURL, Hash: hash}
|
tweet = Tweet{TweetID: tweetID, ProductURL: productURL, Hash: hash, Counter: 1}
|
||||||
trx = c.db.Create(&tweet)
|
trx = c.db.Create(&tweet)
|
||||||
if trx.Error != nil {
|
if trx.Error != nil {
|
||||||
return fmt.Errorf("could not save tweet %d to database for product '%s': %s", tweet.TweetID, productURL, trx.Error)
|
return fmt.Errorf("could not save tweet %d to database for product '%s': %s", tweet.TweetID, productURL, trx.Error)
|
||||||
|
@ -173,48 +174,49 @@ func (c *TwitterNotifier) NotifyWhenAvailable(shopName string, productName strin
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
if !c.enableReplies {
|
// tweet already has been sent in the past
|
||||||
log.Debugf("twitter replies are disabled, skipping available notification for product '%s'", productURL)
|
// creating new thread with a counter
|
||||||
return nil
|
tweet.Counter++
|
||||||
}
|
message = formatAvailableTweet(shopName, productName, productPrice, productCurrency, productURL, hashtags, tweet.Counter)
|
||||||
|
tweetID, err := c.createTweet(message)
|
||||||
// select tweet to reply
|
|
||||||
lastTweetID := CoalesceInt64(tweet.LastTweetID, tweet.TweetID)
|
|
||||||
if lastTweetID == 0 {
|
|
||||||
return fmt.Errorf("could not find original tweet ID to create reply for product '%s'", productURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tweet already has been sent in the past and replies are enabled
|
|
||||||
// continuing thread
|
|
||||||
tweetID, err := c.replyToTweet(lastTweetID, "Good news, it's available again!")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not reply to tweet %d for product '%s': %s", lastTweetID, productURL, err)
|
return fmt.Errorf("could not create new twitter thread for product '%s': %s", productURL, err)
|
||||||
}
|
}
|
||||||
log.Infof("reply to tweet %d sent with id %d for product '%s'", lastTweetID, tweetID, productURL)
|
log.Infof("tweet %d sent for product '%s'", tweetID, productURL)
|
||||||
|
|
||||||
// save thread to database
|
// save thread to database
|
||||||
tweet.LastTweetID = tweetID
|
tweet.LastTweetID = tweetID
|
||||||
if trx = c.db.Save(&tweet); trx.Error != nil {
|
if trx = c.db.Save(&tweet); trx.Error != nil {
|
||||||
return fmt.Errorf("could not save tweet %d to database for product '%s': %s", tweet.TweetID, productURL, trx.Error)
|
return fmt.Errorf("could not save tweet %d to database for product '%s': %s", tweet.TweetID, productURL, trx.Error)
|
||||||
}
|
}
|
||||||
log.Debugf("tweet %d saved in database", tweet.TweetID)
|
log.Debugf("tweet %d saved to database", tweet.TweetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatAvailableTweet creates a message based on product characteristics
|
// formatAvailableTweet creates a message based on product characteristics
|
||||||
func formatAvailableTweet(shopName string, productName string, productPrice float64, productCurrency string, productURL string, hashtags string) string {
|
func formatAvailableTweet(shopName string, productName string, productPrice float64, productCurrency string, productURL string, hashtags string, counter int64) string {
|
||||||
// format message
|
// format message
|
||||||
formattedPrice := formatPrice(productPrice, productCurrency)
|
formattedPrice := formatPrice(productPrice, productCurrency)
|
||||||
message := fmt.Sprintf("%s: %s for %s is available at %s %s", shopName, productName, formattedPrice, productURL, hashtags)
|
message := fmt.Sprintf("%s: %s for %s is available at %s %s", shopName, productName, formattedPrice, productURL, hashtags)
|
||||||
|
if counter > 1 {
|
||||||
|
message = fmt.Sprintf("%s (%d)", message, counter)
|
||||||
|
}
|
||||||
|
|
||||||
// truncate tweet if too big
|
// truncate tweet if too big
|
||||||
if utf8.RuneCountInString(message) > tweetMaxSize {
|
if utf8.RuneCountInString(message) > tweetMaxSize {
|
||||||
|
messageWithoutProduct := fmt.Sprintf("%s: for %s is available at %s %s", shopName, formattedPrice, productURL, hashtags)
|
||||||
|
if counter > 1 {
|
||||||
|
messageWithoutProduct = fmt.Sprintf("%s (%d)", messageWithoutProduct, counter)
|
||||||
|
}
|
||||||
// maximum tweet size - other characters - additional "…" to say product name has been truncated
|
// maximum tweet size - other characters - additional "…" to say product name has been truncated
|
||||||
productNameSize := tweetMaxSize - utf8.RuneCountInString(fmt.Sprintf("%s: for %s is available at %s %s", shopName, formattedPrice, productURL, hashtags)) - 1
|
productNameSize := tweetMaxSize - utf8.RuneCountInString(messageWithoutProduct) - 1
|
||||||
format := fmt.Sprintf("%%s: %%.%ds… for %%s is available at %%s %%s", productNameSize)
|
format := fmt.Sprintf("%%s: %%.%ds… for %%s is available at %%s %%s", productNameSize)
|
||||||
message = fmt.Sprintf(format, shopName, productName, formattedPrice, productURL, hashtags)
|
message = fmt.Sprintf(format, shopName, productName, formattedPrice, productURL, hashtags)
|
||||||
|
if counter > 1 {
|
||||||
|
message = fmt.Sprintf("%s (%d)", message, counter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildHashtags(t *testing.T) {
|
func TestBuildHashtags(t *testing.T) {
|
||||||
|
@ -64,19 +65,68 @@ func TestFormatAvailableTweet(t *testing.T) {
|
||||||
productCurrency string
|
productCurrency string
|
||||||
productURL string
|
productURL string
|
||||||
hashtags string
|
hashtags string
|
||||||
|
counter int64
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"shop.com", "my awesome product", 999.99, "USD", "https://shop.com/awesome", "#awesome #product", "shop.com: my awesome product for $999.99 is available at https://shop.com/awesome #awesome #product"},
|
{
|
||||||
{"shop.com", "my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long name", 999.99, "USD", "https://shop.com/awesome", "#awesome #product", "shop.com: my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very… for $999.99 is available at https://shop.com/awesome #awesome #product"},
|
// short product name
|
||||||
|
"shop.com",
|
||||||
|
"my awesome product",
|
||||||
|
999.99,
|
||||||
|
"USD",
|
||||||
|
"https://shop.com/awesome",
|
||||||
|
"#awesome #product",
|
||||||
|
0,
|
||||||
|
"shop.com: my awesome product for $999.99 is available at https://shop.com/awesome #awesome #product",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// long product name
|
||||||
|
"shop.com",
|
||||||
|
"my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long name",
|
||||||
|
999.99,
|
||||||
|
"USD",
|
||||||
|
"https://shop.com/awesome",
|
||||||
|
"#awesome #product",
|
||||||
|
0,
|
||||||
|
"shop.com: my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very… for $999.99 is available at https://shop.com/awesome #awesome #product",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// short product name with a counter
|
||||||
|
"shop.com",
|
||||||
|
"my awesome product",
|
||||||
|
999.99,
|
||||||
|
"USD",
|
||||||
|
"https://shop.com/awesome",
|
||||||
|
"#awesome #product",
|
||||||
|
2,
|
||||||
|
"shop.com: my awesome product for $999.99 is available at https://shop.com/awesome #awesome #product (2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// long product name with a counter
|
||||||
|
"shop.com",
|
||||||
|
"my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long name",
|
||||||
|
999.99,
|
||||||
|
"USD",
|
||||||
|
"https://shop.com/awesome",
|
||||||
|
"#awesome #product",
|
||||||
|
2,
|
||||||
|
"shop.com: my awesome product with very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very … for $999.99 is available at https://shop.com/awesome #awesome #product (2)",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for i, tc := range tests {
|
for i, tc := range tests {
|
||||||
t.Run(fmt.Sprintf("TestFormatAvailableTweet#%d", i), func(t *testing.T) {
|
t.Run(fmt.Sprintf("TestFormatAvailableTweet#%d", i), func(t *testing.T) {
|
||||||
got := formatAvailableTweet(tc.shopName, tc.productName, tc.productPrice, tc.productCurrency, tc.productURL, tc.hashtags)
|
got := formatAvailableTweet(tc.shopName, tc.productName, tc.productPrice, tc.productCurrency, tc.productURL, tc.hashtags, tc.counter)
|
||||||
if got != tc.expected {
|
if got != tc.expected {
|
||||||
t.Errorf("for %s, got '%s', want '%s'", tc.productName, got, tc.expected)
|
t.Errorf("for %s, got '%s', want '%s'", tc.productName, got, tc.expected)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("for %s, got '%s', want '%s'", tc.productName, got, tc.expected)
|
t.Logf("for %s, got '%s', want '%s'", tc.productName, got, tc.expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensure generated tweet doesn't exceed maximum length
|
||||||
|
if utf8.RuneCountInString(got) > tweetMaxSize {
|
||||||
|
t.Errorf("for %s, got '%s' which exceeed tweet maximum lenght of %d chars", tc.productName, got, tweetMaxSize)
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue