diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..487e7a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin +flexassistant.yaml +flexassistant.db \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1cc0df --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +APPVERSION := $(shell cat ./VERSION) +GOVERSION := $(shell go version | awk '{print $$3}') +GITCOMMIT := $(shell git log -1 --oneline | awk '{print $$1}') +LDFLAGS = -X main.AppVersion=${APPVERSION} -X main.GoVersion=${GOVERSION} -X main.GitCommit=${GITCOMMIT} +PLATFORM := $(shell uname -s) +ARCH := $(shell uname -m) + +.PHONY: clean + +build: + go build -ldflags "${LDFLAGS}" -o bin/flexassistant *.go + +release: + go build -ldflags "${LDFLAGS}" -o bin/flexassistant-${APPVERSION}-${PLATFORM}-${ARCH} *.go + +clean: + rm -rf bin \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a445c5d --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# flexassistant + +[Flexpool.io](https://www.flexpool.io/) is a famous cryptocurrency mining or farming pool supporting +[Ethereum](https://ethereum.org/en/) and [Chia](https://www.chia.net/) blockchains. As a miner, or a farmer, we like to +get **notified** when a **block** is mined, or farmed. We also like to keep track of our **unpaid balance** and our +**transactions** to our personal wallet. + +*flexassistant* is a tool that parses the Flexpool API and sends notifications via [Telegram](https://telegram.org/). + +

+ +

+ + +## Installation + +*Note: this guide has been written with Linux x86_64 in mind.* + +### Binaries + +Go to [Releases](/releases) to download the binary in the version you like (latest is recommended). + +Then extract the tarball: + +``` +tar xvpzf flexassistant-VERSION-Linux-x86_64.tgz +``` + +Write checksum information to a local file: + +``` +echo checksum > flexassistant-VERSION-Linux-x86_64.sha256sum +``` + +Verify checksums to avoid binary corruption: + +``` +sha256sum -c flexassistant-VERSION-Linux-x86_64.sha256sum +``` + +### Compilation + +You will need to install [Go](https://golang.org/dl/), [Git](https://git-scm.com/) and a development toolkit (including [make](https://linux.die.net/man/1/make)) for your environment. + +Then, you'll need to download and compile the source code: + +``` +git clone https://github.com/jouir/flexassistant.git +cd flexassistant +make +``` + +The binary will be available under the `bin` directory: + +``` +ls -l bin/flexassistant +``` + +## Configuration + +*flexassistant* can be configured using a YaML file. By default, the `flexassistant.yaml` file is used but it can be another file provided by the `-config` argument. + +As a good start, you can copy the configuration file example: + +``` +cp -p flexassistant.yaml.example flexassistant.yaml +``` + +Then edit this file at will. + +Reference: +* `database-file` (optional): file name of the database file to persist information between two executions (SQLite database) +* `max-blocks` (optional): maximum number of blocks to retreive from the API +* `max-payments` (optional): maximum number of payments to retreive from the API +* `pools` (optional): list of pools + * `coin`: coin of the pool (ex: `eth`, `xch`) + * `enable-blocks` (optional): enable block notifications for this pool (disabled by default) +* `miners` (optional): list of miners and/or farmers + * `address`: address of the miner or the farmer registered on the API + * `enable-balance` (optional): enable balance notifications (disabled by default) + * `enable-payments` (optional): enable payments notifications (disabled by default) +* `telegram`: Telegram configuration + * `token`: token of the Telegram bot + * `chat-id` (optional if `channel-name` is present): chat identifier to send Telegram notifications + * `channel-name` (optional if `chat-id` is present): channel name to send Telegram notifications + +## Usage + +``` +Usage of ./flexassistant: + -config string + Configuration file name (default "flexassistant.yaml") + -debug + Print even more logs + -quiet + Log errors only + -verbose + Print more logs + -version + Print version and exit +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9f8e9b6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0 \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..674b8f4 --- /dev/null +++ b/client.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "sort" + "time" + + log "github.com/sirupsen/logrus" +) + +// FlexpoolAPIURL constant to store Flexpool API URL +const FlexpoolAPIURL = "https://api.flexpool.io/v2" + +// MaxIterations to avoid infinite loop while requesting paged routes on Flexpool API +const MaxIterations = 10 + +// UserAgent to identify ourselves on the Flexpool API +var UserAgent = fmt.Sprintf("flexassistant/%s", AppVersion) + +// FlexpoolClient to store the HTTP client +type FlexpoolClient struct { + client *http.Client +} + +// NewFlexpoolClient to create a client to manage Flexpool API calls +func NewFlexpoolClient() *FlexpoolClient { + return &FlexpoolClient{ + client: &http.Client{Timeout: time.Second * 3}, + } +} + +// request to create an HTTPS request, call the Flexpool API, detect errors and return the result +func (f *FlexpoolClient) request(url string) (result map[string]interface{}, err error) { + log.Debugf("Requesting %s", url) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", UserAgent) + + resp, err := f.client.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + jsonBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + json.Unmarshal(jsonBody, &result) + + if result["error"] == nil { + return result["result"].(map[string]interface{}), nil + } + return nil, fmt.Errorf("Flexpool API error: %s", result["error"].(string)) +} + +// MinerBalance returns the current unpaid balance +func (f *FlexpoolClient) MinerBalance(coin string, address string) (float64, error) { + response, err := f.request(fmt.Sprintf("%s/miner/balance?coin=%s&address=%s", FlexpoolAPIURL, coin, address)) + if err != nil { + return 0, err + } + return response["balance"].(float64), nil +} + +// MinerPayments returns an ordered list of payments +func (f *FlexpoolClient) MinerPayments(coin string, address string, limit int) (payments []*Payment, err error) { + page := 0 + for { + response, err := f.request(fmt.Sprintf("%s/miner/payments/?coin=%s&address=%s&page=%d", FlexpoolAPIURL, coin, address, page)) + if err != nil { + return nil, err + } + + for _, result := range response["data"].([]interface{}) { + raw := result.(map[string]interface{}) + payment := NewPayment( + raw["hash"].(string), + raw["value"].(float64), + raw["timestamp"].(float64), + ) + payments = append(payments, payment) + if len(payments) >= limit { + // sort by timestamp + sort.Slice(payments, func(p1, p2 int) bool { + return payments[p1].Timestamp > payments[p2].Timestamp + }) + return payments, nil + } + } + page = page + 1 + if page > MaxIterations { + return nil, fmt.Errorf("Max iterations of %d reached", MaxIterations) + } + } +} + +// PoolBlocks returns an ordered list of blocks +func (f *FlexpoolClient) PoolBlocks(coin string, limit int) (blocks []*Block, err error) { + page := 0 + for { + response, err := f.request(fmt.Sprintf("%s/pool/blocks/?coin=%s&page=%d", FlexpoolAPIURL, coin, page)) + if err != nil { + return nil, err + } + + for _, result := range response["data"].([]interface{}) { + raw := result.(map[string]interface{}) + block := NewBlock( + raw["hash"].(string), + raw["number"].(float64), + raw["reward"].(float64), + ) + blocks = append(blocks, block) + if len(blocks) >= limit { + // sort by number + sort.Slice(blocks, func(b1, b2 int) bool { + return blocks[b1].Number < blocks[b2].Number + }) + return blocks, nil + } + } + page = page + 1 + if page > MaxIterations { + return nil, fmt.Errorf("Max iterations of %d reached", MaxIterations) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..553ed33 --- /dev/null +++ b/config.go @@ -0,0 +1,59 @@ +package main + +import ( + "io/ioutil" + + "gopkg.in/yaml.v3" +) + +// Config to receive settings from the configuration file +type Config struct { + DatabaseFile string `yaml:"database-file"` + MaxBlocks int `yaml:"max-blocks"` + MaxPayments int `yaml:"max-payments"` + Pools []PoolConfig `yaml:"pools"` + Miners []MinerConfig `yaml:"miners"` + TelegramConfig TelegramConfig `yaml:"telegram"` +} + +// PoolConfig to store Pool configuration +type PoolConfig struct { + Coin string `yaml:"coin"` + EnableBlocks bool `yaml:"enable-blocks"` +} + +// MinerConfig to store Miner configuration +type MinerConfig struct { + Address string `yaml:"address"` + EnableBalance bool `yaml:"enable-balance"` + EnablePayments bool `yaml:"enable-payments"` +} + +// TelegramConfig to store Telegram configuration +type TelegramConfig struct { + Token string `yaml:"token"` + ChatID int64 `yaml:"chat-id"` + ChannelName string `yaml:"channel-name"` +} + +// NewConfig creates a Config with default values +func NewConfig() *Config { + return &Config{ + DatabaseFile: AppName + ".db", + } +} + +// ReadFile reads and parses a YAML configuration file to override default values +func (c *Config) ReadFile(filename string) (err error) { + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + err = yaml.Unmarshal(yamlFile, &c) + if err != nil { + return err + } + + return nil +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..ada9ce8 --- /dev/null +++ b/db.go @@ -0,0 +1,22 @@ +package main + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// NewDatabase creates a SQLite database object from a file name +func NewDatabase(filename string) (*gorm.DB, error) { + return gorm.Open(sqlite.Open(filename), &gorm.Config{}) +} + +// CreateDatabaseObjects creates database relations +func CreateDatabaseObjects(db *gorm.DB) error { + if err := db.AutoMigrate(&Miner{}); err != nil { + return err + } + if err := db.AutoMigrate(&Pool{}); err != nil { + return err + } + return nil +} diff --git a/flexassistant.yaml.example b/flexassistant.yaml.example new file mode 100644 index 0000000..7320ac4 --- /dev/null +++ b/flexassistant.yaml.example @@ -0,0 +1,20 @@ +--- +database-file: flexassistant.db +max-blocks: 10 +max-payments: 5 +miners: + - address: 0x0000000000000000000000000000000000000000 + enable-balance: true + enable-payments: true + - address: xch00000000000000000000000000000000000000000000000000000000000 + enable-balance: true + enable-payments: true +pools: + - coin: eth + enable-blocks: true + - coin: xch + enable-blocks: true +telegram: + chat-id: 000000000 + channel-name: MyTelegramChannel + token: 0000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5aba9cd --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/jouir/flexassistant + +go 1.16 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1 + github.com/leekchan/accounting v1.0.0 // indirect + github.com/sirupsen/logrus v1.8.1 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + gorm.io/driver/sqlite v1.1.5 + gorm.io/gorm v1.21.15 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e520815 --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI= +github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/leekchan/accounting v1.0.0 h1:+Wd7dJ//dFPa28rc1hjyy+qzCbXPMR91Fb6F1VGTQHg= +github.com/leekchan/accounting v1.0.0/go.mod h1:3timm6YPhY3YDaGxl0q3eaflX0eoSx3FXn7ckHe4tO0= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= +github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.1.5 h1:JU8G59VyKu1x1RMQgjefQnkZjDe9wHc1kARDZPu5dZs= +gorm.io/driver/sqlite v1.1.5/go.mod h1:NpaYMcVKEh6vLJ47VP6T7Weieu4H1Drs3dGD/K6GrGc= +gorm.io/gorm v1.21.15 h1:gAyaDoPw0lCyrSFWhBlahbUA1U4P5RViC1uIqoB+1Rk= +gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d25d02d --- /dev/null +++ b/main.go @@ -0,0 +1,239 @@ +package main + +import ( + "flag" + "fmt" + "os" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// AppName to store application name +var AppName string = "flexassistant" + +// AppVersion to set version at compilation time +var AppVersion string = "9999" + +// GitCommit to set git commit at compilation time (can be empty) +var GitCommit string + +// GoVersion to set Go version at compilation time +var GoVersion string + +// MaxPayments defaults +const MaxPayments = 10 + +// MaxBlocks defaults +const MaxBlocks = 50 + +// initialize logging +func init() { + log.SetOutput(os.Stdout) +} + +func main() { + config := NewConfig() + version := flag.Bool("version", false, "Print version and exit") + quiet := flag.Bool("quiet", false, "Log errors only") + verbose := flag.Bool("verbose", false, "Print more logs") + debug := flag.Bool("debug", false, "Print even more logs") + configFileName := flag.String("config", AppName+".yaml", "Configuration file name") + flag.Parse() + + if *version { + showVersion() + return + } + + // Logs and configuration + log.SetLevel(log.WarnLevel) + if *debug { + log.SetLevel(log.DebugLevel) + } + if *verbose { + log.SetLevel(log.InfoLevel) + } + if *quiet { + log.SetLevel(log.ErrorLevel) + } + + if *configFileName != "" { + err := config.ReadFile(*configFileName) + if err != nil { + log.Fatalf("Cannot parse configuration file: %v", err) + } + } + + // Database + var db *gorm.DB + db, err := NewDatabase(config.DatabaseFile) + if err != nil { + log.Fatalf("Could not create database: %v", err) + } + + if err := CreateDatabaseObjects(db); err != nil { + log.Fatalf("Could not create objects: %v", err) + } + + // API client + client := NewFlexpoolClient() + + // Notifications + notifier, err := NewTelegramNotifier(&config.TelegramConfig) + if err != nil { + log.Fatalf("Could not create notifier: %v", err) + } + + // Limits + var maxPayments int + if config.MaxPayments > 0 { + maxPayments = config.MaxPayments + } else { + maxPayments = MaxPayments + } + + var maxBlocks int + if config.MaxBlocks > 0 { + maxBlocks = config.MaxBlocks + } else { + maxBlocks = MaxBlocks + } + + // Handle miners + for _, configuredMiner := range config.Miners { + miner, err := NewMiner(configuredMiner.Address) + if err != nil { + log.Warnf("Could not parse miner: %v", err) + continue + } + + var dbMiner Miner + trx := db.Where(Miner{Address: miner.Address}).Attrs(Miner{Address: miner.Address, Coin: miner.Coin}).FirstOrCreate(&dbMiner) + if trx.Error != nil { + log.Warnf("Cannot fetch miner %s from database: %v", miner, trx.Error) + } + + // Balance management + if configuredMiner.EnableBalance { + // Balance have never been persisted, skip notifications + notify := true + if dbMiner.Balance == 0 { + notify = false + } + + log.Debugf("Fetching balance for %s", miner) + balance, err := client.MinerBalance(miner.Coin, miner.Address) + if err != nil { + log.Warnf("Could not fetch unpaid balance: %v", err) + continue + } + log.Debugf("Unpaid balance %.0f", balance) + miner.Balance = balance + if miner.Balance != dbMiner.Balance { + dbMiner.Balance = balance + if trx = db.Save(&dbMiner); trx.Error != nil { + log.Warnf("Cannot update miner: %v", trx.Error) + continue + } + if notify { + err = notifier.NotifyBalance(*miner) + if err != nil { + log.Warnf("Cannot send notification: %v", err) + continue + } + log.Infof("Balance notification sent for %s", miner) + } + } + } + + // Payments management + if configuredMiner.EnablePayments { + // Payments have never been persisted, skip notifications + notify := true + if dbMiner.LastPaymentTimestamp == 0 { + notify = false + } + + log.Debugf("Fetching payments for %s", miner) + + payments, err := client.MinerPayments(miner.Coin, miner.Address, maxPayments) + if err != nil { + log.Warnf("Could not fetch payments: %v", err) + continue + } + for _, payment := range payments { + log.Debugf("Fetched %s", payment) + if dbMiner.LastPaymentTimestamp < payment.Timestamp { + dbMiner.LastPaymentTimestamp = payment.Timestamp + if trx = db.Save(&dbMiner); trx.Error != nil { + log.Warnf("Cannot update miner: %v", trx.Error) + continue + } + if notify { + err = notifier.NotifyPayment(*miner, *payment) + if err != nil { + log.Warnf("Cannot send notification: %v", err) + continue + } + log.Infof("Payment notification sent for %s", payment) + } + } + } + } + } + + // Handle pools + for _, configuredPool := range config.Pools { + + pool := NewPool(configuredPool.Coin) + + var dbPool Pool + trx := db.Where(Pool{Coin: pool.Coin}).Attrs(Pool{Coin: pool.Coin}).FirstOrCreate(&dbPool) + if trx.Error != nil { + log.Warnf("Cannot fetch pool %s from database: %v", pool, trx.Error) + } + + // Blocks management + if configuredPool.EnableBlocks { + + // Block number has never been persisted, skip notifications + notify := true + if dbPool.LastBlockNumber == 0 { + notify = false + } + + log.Debugf("Fetching blocks for %s", pool) + blocks, err := client.PoolBlocks(pool.Coin, maxBlocks) + if err != nil { + log.Warnf("Could not fetch blocks: %v", err) + } else { + for _, block := range blocks { + log.Debugf("Fetched %s", block) + if dbPool.LastBlockNumber < block.Number { + dbPool.LastBlockNumber = block.Number + if trx = db.Save(&dbPool); trx.Error != nil { + log.Warnf("Cannot update pool: %v", trx.Error) + continue + } + if notify { + err = notifier.NotifyBlock(*pool, *block) + if err != nil { + log.Warnf("Cannot send notification: %v", err) + continue + } + log.Infof("Block notification sent for %s", block) + } + } + } + } + } + } +} + +func showVersion() { + if GitCommit != "" { + AppVersion = fmt.Sprintf("%s-%s", AppVersion, GitCommit) + } + fmt.Printf("%s version %s (compiled with %s)\n", AppName, AppVersion, GoVersion) +} diff --git a/miner.go b/miner.go new file mode 100644 index 0000000..980d841 --- /dev/null +++ b/miner.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +// CoinLengthETH represents the size of an ETH address +const CoinLengthETH = 42 + +// CoinLengthXCH represents the size of an XCH address +const CoinLengthXCH = 62 + +// Miner to store miner attributes +type Miner struct { + gorm.Model + Coin string + Address string `gorm:"unique;not null"` + Balance float64 + LastPaymentTimestamp float64 +} + +// NewMiner creates a Miner +func NewMiner(address string) (*Miner, error) { + miner := &Miner{Address: address} + coin, err := miner.ParseCoin() + if err != nil { + return nil, err + } + miner.Coin = coin + return miner, nil +} + +// ParseCoin deduces the currency given the miner address +func (m *Miner) ParseCoin() (coin string, err error) { + if m.Address == "" { + return "", fmt.Errorf("Miner address is empty") + } + if len(m.Address) == CoinLengthETH && strings.HasPrefix(m.Address, "0x") { + return "eth", nil + } + if len(m.Address) == CoinLengthXCH && strings.HasPrefix(m.Address, "xch") { + return "xch", nil + } + return "", fmt.Errorf("Unsupported address") +} + +// String represents Miner to a printable format +func (m *Miner) String() string { + return fmt.Sprintf("Miner<%s>", m.Address) +} + +// Payment to store payment attributes +type Payment struct { + Hash string + Value float64 + Timestamp float64 +} + +// NewPayment creates a Payment +func NewPayment(hash string, value float64, timestamp float64) *Payment { + return &Payment{ + Hash: hash, + Value: value, + Timestamp: timestamp, + } +} + +// String represents a Payment to a printable format +func (p *Payment) String() string { + return fmt.Sprintf("Payment<%s>", p.Hash) +} diff --git a/notification.go b/notification.go new file mode 100644 index 0000000..a4e3713 --- /dev/null +++ b/notification.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/leekchan/accounting" + + telegram "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" +) + +// Notifier interface to define how to send all kind of notifications +type Notifier interface { + NotifyBalance(miner Miner, difference float64) error + NotifyPayment(miner Miner, payment Payment) error + NotifyBlock(pool Pool, block Block) error +} + +// TelegramNotifier to send notifications using Telegram +// Implements the Notifier interface +type TelegramNotifier struct { + bot *telegram.BotAPI + chatID int64 + channelName string +} + +// NewTelegramNotifier to create a TelegramNotifier +func NewTelegramNotifier(config *TelegramConfig) (*TelegramNotifier, error) { + bot, err := telegram.NewBotAPI(config.Token) + if err != nil { + return nil, err + } + log.Debugf("Connected to Telegram as %s", bot.Self.UserName) + + return &TelegramNotifier{ + bot: bot, + chatID: config.ChatID, + channelName: config.ChannelName, + }, nil +} + +// sendMessage to send a generic message on Telegram +func (t *TelegramNotifier) sendMessage(message string) error { + var request telegram.MessageConfig + if t.chatID != 0 { + request = telegram.NewMessage(t.chatID, message) + } else { + request = telegram.NewMessageToChannel(t.channelName, message) + } + request.DisableWebPagePreview = true + request.ParseMode = telegram.ModeMarkdown + + response, err := t.bot.Send(request) + if err != nil { + return err + } + log.Debugf("Message %d sent to Telegram", response.MessageID) + return nil +} + +// NotifyBalance to format and send a notification when the unpaid balance has changed +// Implements the Notifier interface +func (t *TelegramNotifier) NotifyBalance(miner Miner) error { + ac := accounting.Accounting{ + Symbol: strings.ToUpper(miner.Coin), + Precision: 6, + Format: "%v %s", + } + convertedBalance, err := ConvertCurrency(miner.Coin, miner.Balance) + if err != nil { + return err + } + message := fmt.Sprintf("💰 *Balance* _%s_", ac.FormatMoney(convertedBalance)) + return t.sendMessage(message) +} + +// NotifyPayment to format and send a notification when a new payment has been detected +// Implements the Notifier interface +func (t *TelegramNotifier) NotifyPayment(miner Miner, payment Payment) error { + ac := accounting.Accounting{ + Symbol: strings.ToUpper(miner.Coin), + Precision: 6, + Format: "%v %s", + } + convertedValue, err := ConvertCurrency(miner.Coin, payment.Value) + if err != nil { + return err + } + + message := fmt.Sprintf("💵 *Payment* _%s_", ac.FormatMoney(convertedValue)) + return t.sendMessage(message) +} + +// NotifyBlock to format and send a notification when a new block has been detected +// Implements the Notifier interface +func (t *TelegramNotifier) NotifyBlock(pool Pool, block Block) error { + precision := 6 + if pool.Coin == "xch" { + precision = 2 + } + ac := accounting.Accounting{ + Symbol: strings.ToUpper(pool.Coin), + Precision: precision, + Format: "%v %s", + } + + convertedValue, err := ConvertCurrency(pool.Coin, block.Reward) + if err != nil { + return err + } + verb, err := ConvertAction(pool.Coin) + if err != nil { + return err + } + url, err := FormatBlockURL(pool.Coin, block.Hash) + if err != nil { + return err + } + + message := fmt.Sprintf("🎉 *%s* [#%.0f](%s) _%s_", verb, block.Number, url, ac.FormatMoney(convertedValue)) + return t.sendMessage(message) +} diff --git a/pool.go b/pool.go new file mode 100644 index 0000000..d69947e --- /dev/null +++ b/pool.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + + "gorm.io/gorm" +) + +// Pool to store pool attributes +type Pool struct { + gorm.Model + Coin string `gorm:"unique;not null"` + LastBlockNumber float64 +} + +// NewPool creates a Pool +func NewPool(coin string) *Pool { + return &Pool{Coin: coin} +} + +// String represents Pool to a printable format +func (p *Pool) String() string { + return fmt.Sprintf("Pool<%s>", p.Coin) +} + +// Block to store block attributes +type Block struct { + Hash string `gorm:"unique;not null"` + Number float64 `gorm:"not null"` + Reward float64 `gorm:"not null"` +} + +// NewBlock creates a Block +func NewBlock(hash string, number float64, reward float64) *Block { + return &Block{ + Hash: hash, + Number: number, + Reward: reward, + } +} + +// String represents Block to a printable format +func (b *Block) String() string { + return fmt.Sprintf("Block<%.0f>", b.Number) +} diff --git a/static/screenshot.jpg b/static/screenshot.jpg new file mode 100644 index 0000000..74cd6c3 Binary files /dev/null and b/static/screenshot.jpg differ diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..fa57434 --- /dev/null +++ b/utils.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" +) + +// WeisToETHDivider to divide Weis to ETH +const WeisToETHDivider = 1000000000000000000 + +// MojoToXCHDivider to divide Mojo to XCH +const MojoToXCHDivider = 1000000000000 + +// ConvertCurrency divides the smallest unit of the currency to the currency itself +// Example: for "eth", convert from Weis to ETH +func ConvertCurrency(coin string, value float64) (float64, error) { + switch coin { + case "eth": + return ConvertWeis(value), nil + case "xch": + return ConvertMojo(value), nil + default: + return 0, fmt.Errorf("Coin %s not supported", coin) + } +} + +// ConvertWeis converts the value from Weis to ETH +func ConvertWeis(value float64) float64 { + return value / WeisToETHDivider +} + +// ConvertMojo converts the value from Mojo to XCH +func ConvertMojo(value float64) float64 { + return value / MojoToXCHDivider +} + +// ConvertAction returns "Miner" for Ethereum and "Farmer" for Chia +// Because Chia is farmed and not mined +func ConvertAction(coin string) (string, error) { + switch coin { + case "eth": + return "Mined", nil + case "xch": + return "Farmed", nil + } + return "", fmt.Errorf("Coin %s not supported", coin) +} + +// FormatBlockURL returns the URL on the respective blockchain explorer given the coin and the block hash +func FormatBlockURL(coin string, hash string) (string, error) { + switch coin { + case "eth": + return fmt.Sprintf("https://etherscan.io/block/%s", hash), nil + case "xch": + return fmt.Sprintf("https://www.chiaexplorer.com/blockchain/block/%s", hash), nil + } + return "", fmt.Errorf("Coin %s not supported", coin) +} + +// FormatTransactionURL returns the URL on the respective blockchain explorer given the coin and the transaction hash +func FormatTransactionURL(coin string, hash string) (string, error) { + switch coin { + case "eth": + return fmt.Sprintf("https://etherscan.io/tx/%s", hash), nil + case "xch": + return fmt.Sprintf("https://www.chiaexplorer.com/blockchain/coin/%s", hash), nil + } + return "", fmt.Errorf("Coin %s not supported", coin) +}