2021-02-23 18:29:27 +01:00
package main
import (
2021-04-15 15:13:00 +02:00
"crypto/md5"
2021-02-23 18:29:27 +01:00
"fmt"
"regexp"
"strings"
"time"
2021-03-02 08:56:47 +01:00
"unicode/utf8"
2021-02-23 18:29:27 +01:00
"github.com/dghubble/go-twitter/twitter"
"github.com/dghubble/oauth1"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
2021-03-02 08:56:47 +01:00
// maximum number of characters a tweet can support
const tweetMaxSize = 280
2021-02-23 18:29:27 +01:00
// Tweet to store relationship between a Product and a Twitter notification
type Tweet struct {
gorm . Model
2021-04-15 15:13:00 +02:00
TweetID int64 ` gorm:"not null;unique" `
Hash string ` gorm:"unique" `
LastTweetID int64 ` gorm:"index" `
2021-04-23 12:22:18 +02:00
Counter int64 ` gorm:"not null;default:1" `
2021-04-15 15:13:00 +02:00
ProductURL string ` gorm:"index" `
2021-05-14 16:08:09 +02:00
Product Product ` gorm:"not null;references:URL;constraint:OnDelete:CASCADE" `
2021-02-23 18:29:27 +01:00
}
// TwitterNotifier to manage notifications to Twitter
type TwitterNotifier struct {
2021-04-01 13:14:00 +02:00
db * gorm . DB
client * twitter . Client
user * twitter . User
hashtagsMap [ ] map [ string ] string
enableReplies bool
2021-04-15 15:13:00 +02:00
retentionDays int
2021-02-23 18:29:27 +01:00
}
// NewTwitterNotifier creates a TwitterNotifier
func NewTwitterNotifier ( c * TwitterConfig , db * gorm . DB ) ( * TwitterNotifier , error ) {
// create table
err := db . AutoMigrate ( & Tweet { } )
if err != nil {
return nil , err
}
// create twitter client
config := oauth1 . NewConfig ( c . ConsumerKey , c . ConsumerSecret )
token := oauth1 . NewToken ( c . AccessToken , c . AccessTokenSecret )
httpClient := config . Client ( oauth1 . NoContext , token )
client := twitter . NewClient ( httpClient )
verifyParams := & twitter . AccountVerifyParams {
SkipStatus : twitter . Bool ( true ) ,
IncludeEmail : twitter . Bool ( true ) ,
}
// verify credentials at least once
user , _ , err := client . Accounts . VerifyCredentials ( verifyParams )
if err != nil {
return nil , err
}
log . Debugf ( "connected to twitter as @%s" , user . ScreenName )
2021-04-15 15:13:00 +02:00
notifier := & TwitterNotifier {
client : client ,
user : user ,
hashtagsMap : c . Hashtags ,
db : db ,
enableReplies : c . EnableReplies ,
retentionDays : c . Retention ,
}
// delete old tweets
if err = notifier . ensureRetention ( ) ; err != nil {
return nil , err
}
return notifier , nil
}
// ensureRetention deletes tweets according to the defined retention
func ( c * TwitterNotifier ) ensureRetention ( ) error {
if c . retentionDays == 0 {
log . Debugf ( "tweet retention not found, skipping database cleanup" )
return nil
}
var oldTweets [ ] Tweet
retentionDate := time . Now ( ) . Local ( ) . Add ( - time . Hour * 24 * time . Duration ( c . retentionDays ) )
trx := c . db . Where ( "updated_at < ?" , retentionDate ) . Find ( & oldTweets )
if trx . Error != nil {
return fmt . Errorf ( "cannot find twitter old statuses: %s" , trx . Error )
}
for _ , t := range oldTweets {
log . Debugf ( "twitter old status found with id %d" , t . TweetID )
if trx = c . db . Unscoped ( ) . Delete ( & t ) ; trx . Error != nil {
log . Warnf ( "cannot remove old tweet %d: %s" , t . TweetID , trx . Error )
} else {
log . Infof ( "twitter old status %d removed from database" , t . TweetID )
}
}
return nil
2021-02-23 18:29:27 +01:00
}
// create a brand new tweet
func ( c * TwitterNotifier ) createTweet ( message string ) ( int64 , error ) {
tweet , _ , err := c . client . Statuses . Update ( message , nil )
if err != nil {
return 0 , err
}
log . Debugf ( "twitter status %d created: %s" , tweet . ID , tweet . Text )
return tweet . ID , nil
}
// reply to another tweet
func ( c * TwitterNotifier ) replyToTweet ( tweetID int64 , message string ) ( int64 , error ) {
message = fmt . Sprintf ( "@%s %s" , c . user . ScreenName , message )
tweet , _ , err := c . client . Statuses . Update ( message , & twitter . StatusUpdateParams { InReplyToStatusID : tweetID } )
if err != nil {
return 0 , nil
}
log . Debugf ( "twitter status %d created: %s" , tweet . ID , tweet . Text )
return tweet . ID , nil
}
// parse product name to build a list of hashtags
func ( c * TwitterNotifier ) buildHashtags ( productName string ) string {
productName = strings . ToLower ( productName )
2021-02-27 18:31:11 +01:00
for _ , rule := range c . hashtagsMap {
for pattern , value := range rule {
if ok , _ := regexp . MatchString ( pattern , productName ) ; ok {
return value
}
2021-02-23 18:29:27 +01:00
}
}
return ""
}
// 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 {
2021-04-15 15:13:00 +02:00
// format message
2021-02-23 18:29:27 +01:00
hashtags := c . buildHashtags ( productName )
2021-04-23 12:22:18 +02:00
message := formatAvailableTweet ( shopName , productName , productPrice , productCurrency , productURL , hashtags , 0 )
2021-04-15 15:13:00 +02:00
// compute message checksum to avoid duplicates
var tweet Tweet
hash := fmt . Sprintf ( "%x" , md5 . Sum ( [ ] byte ( message ) ) )
trx := c . db . Where ( Tweet { Hash : hash } ) . First ( & tweet )
if trx . Error != nil && trx . Error != gorm . ErrRecordNotFound {
return fmt . Errorf ( "could not search for tweet with hash %s for product '%s': %s" , hash , productURL , trx . Error )
2021-02-23 18:29:27 +01:00
}
2021-04-15 15:13:00 +02:00
if trx . Error == gorm . ErrRecordNotFound {
// tweet has not been sent in the past
// create thread
tweetID , err := c . createTweet ( message )
if err != nil {
return fmt . Errorf ( "could not create new twitter thread for product '%s': %s" , productURL , err )
}
log . Infof ( "tweet %d sent for product '%s'" , tweetID , productURL )
// save thread to database
2021-04-23 12:22:18 +02:00
tweet = Tweet { TweetID : tweetID , ProductURL : productURL , Hash : hash , Counter : 1 }
2021-04-15 15:13:00 +02:00
trx = c . db . Create ( & tweet )
if trx . Error != nil {
return fmt . Errorf ( "could not save tweet %d to database for product '%s': %s" , tweet . TweetID , productURL , trx . Error )
}
log . Debugf ( "tweet %d saved to database" , tweet . TweetID )
} else {
2021-04-23 12:22:18 +02:00
// tweet already has been sent in the past
// creating new thread with a counter
tweet . Counter ++
message = formatAvailableTweet ( shopName , productName , productPrice , productCurrency , productURL , hashtags , tweet . Counter )
tweetID , err := c . createTweet ( message )
2021-04-15 15:13:00 +02:00
if err != nil {
2021-04-23 12:22:18 +02:00
return fmt . Errorf ( "could not create new twitter thread for product '%s': %s" , productURL , err )
2021-04-15 15:13:00 +02:00
}
2021-04-23 12:22:18 +02:00
log . Infof ( "tweet %d sent for product '%s'" , tweetID , productURL )
2021-04-15 15:13:00 +02:00
// save thread to database
tweet . LastTweetID = tweetID
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 )
}
2021-04-23 12:22:18 +02:00
log . Debugf ( "tweet %d saved to database" , tweet . TweetID )
2021-02-23 18:29:27 +01:00
}
2021-04-15 15:13:00 +02:00
2021-02-23 18:29:27 +01:00
return nil
}
2021-04-15 15:13:00 +02:00
// formatAvailableTweet creates a message based on product characteristics
2021-04-23 12:22:18 +02:00
func formatAvailableTweet ( shopName string , productName string , productPrice float64 , productCurrency string , productURL string , hashtags string , counter int64 ) string {
2021-03-02 08:56:47 +01:00
// format message
formattedPrice := formatPrice ( productPrice , productCurrency )
message := fmt . Sprintf ( "%s: %s for %s is available at %s %s" , shopName , productName , formattedPrice , productURL , hashtags )
2021-04-23 12:22:18 +02:00
if counter > 1 {
message = fmt . Sprintf ( "%s (%d)" , message , counter )
}
2021-03-02 08:56:47 +01:00
// truncate tweet if too big
if utf8 . RuneCountInString ( message ) > tweetMaxSize {
2021-04-23 12:22:18 +02:00
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 )
}
2021-03-02 08:56:47 +01:00
// maximum tweet size - other characters - additional "…" to say product name has been truncated
2021-04-23 12:22:18 +02:00
productNameSize := tweetMaxSize - utf8 . RuneCountInString ( messageWithoutProduct ) - 1
2021-03-02 08:56:47 +01:00
format := fmt . Sprintf ( "%%s: %%.%ds… for %%s is available at %%s %%s" , productNameSize )
message = fmt . Sprintf ( format , shopName , productName , formattedPrice , productURL , hashtags )
2021-04-23 12:22:18 +02:00
if counter > 1 {
message = fmt . Sprintf ( "%s (%d)" , message , counter )
}
2021-03-02 08:56:47 +01:00
}
return message
}
2021-02-23 18:29:27 +01:00
// NotifyWhenNotAvailable create a Twitter status replying to the NotifyWhenAvailable status to say it's over
// implements the Notifier interface
func ( c * TwitterNotifier ) NotifyWhenNotAvailable ( productURL string , duration time . Duration ) error {
// find Tweet in the database
var tweet Tweet
trx := c . db . Where ( Tweet { ProductURL : productURL } ) . First ( & tweet )
2021-04-15 15:13:00 +02:00
2021-02-23 18:29:27 +01:00
if trx . Error != nil {
2021-04-15 15:13:00 +02:00
return fmt . Errorf ( "could not find tweet for product '%s' in the database: %s" , productURL , trx . Error )
2021-02-23 18:29:27 +01:00
}
2021-04-01 13:14:00 +02:00
if c . enableReplies {
// format message
message := fmt . Sprintf ( "And it's gone (%s)" , duration )
2021-02-23 18:29:27 +01:00
2021-04-15 15:13:00 +02:00
// 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 )
}
2021-04-01 13:14:00 +02:00
// close thread on twitter
2021-04-15 15:13:00 +02:00
tweetID , err := c . replyToTweet ( lastTweetID , message )
2021-04-01 13:14:00 +02:00
if err != nil {
2021-04-15 15:13:00 +02:00
return fmt . Errorf ( "could not close thread on twitter for product '%s': %s" , productURL , err )
2021-04-01 13:14:00 +02:00
}
2021-04-15 15:13:00 +02:00
log . Infof ( "reply to tweet %d sent with id %d for product '%s'" , lastTweetID , tweetID , productURL )
2021-02-23 18:29:27 +01:00
2021-04-15 15:13:00 +02:00
// save tweet id on database
tweet . LastTweetID = tweetID
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 )
}
log . Debugf ( "tweet %d saved in database" , tweet . TweetID )
} else {
log . Debugf ( "twitter replies are disabled, skipping not available notification for '%s'" , productURL )
2021-02-23 18:29:27 +01:00
}
return nil
}