From e2f0589444178e269a2f365fe8c4268e2614ffcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Sun, 26 Nov 2023 15:38:22 +0100 Subject: [PATCH] Setup daily notifier and adapt worker to handle multiple scheduled jobs as abstraction (#6) --- config.yml | 8 +++++-- daily_notification.go | 37 ++++++++++++++++++++++++++++++ fetch_worker.go | 31 ++++++++++++++++++-------- main.go | 6 ++++- notifier.go | 35 +++++++++++++++++++++++++++++ scheduler.go | 43 +++++++++++++++++++++++++++++++++++ telegram_notifier.go | 52 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 daily_notification.go create mode 100644 notifier.go create mode 100644 scheduler.go create mode 100644 telegram_notifier.go diff --git a/config.yml b/config.yml index 1e05cfa..5231d86 100644 --- a/config.yml +++ b/config.yml @@ -6,7 +6,11 @@ database: github: user: flexicon - token: "" + token: '' + +telegram: + bot_token: '' + chat_id: '' fetch_worker: - schedule: "*/10 * * * *" + schedule: '*/10 * * * *' diff --git a/daily_notification.go b/daily_notification.go new file mode 100644 index 0000000..cd16bfa --- /dev/null +++ b/daily_notification.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "log" + + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + DailyNotificationTemplate = `đŸ“ĸ *LateNightCommits Daily Notifier* 📊 + +Fetched a total of %d commits today\. + +ℹī¸ Check out the [stats page](https://latenightcommits.com/api/stats) for more\.` +) + +func runDailyNotification(db *gorm.DB, notifier Notifier) error { + log.Println("đŸ“Ŧ Running Daily Notification job...") + + var sentToday int64 + if err := db.Raw( + `SELECT COUNT(id) FROM commits WHERE created_at BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL 1 DAY);`, + ).Scan(&sentToday).Error; err != nil { + return errors.Wrap(err, "failed to retrieve daily fetched amount for notifier") + } + + msg := fmt.Sprintf(DailyNotificationTemplate, sentToday) + + if err := notifier.Notify(msg); err != nil { + return errors.Wrap(err, "failed to notify") + } + + log.Println("Successfully sent daily notification!") + return nil +} diff --git a/fetch_worker.go b/fetch_worker.go index af90e8b..8703684 100644 --- a/fetch_worker.go +++ b/fetch_worker.go @@ -6,13 +6,11 @@ import ( "net/http" "time" - "github.com/pkg/errors" - "github.com/robfig/cron/v3" "github.com/spf13/viper" "gorm.io/gorm" ) -func runFetchWorker(db *gorm.DB, api *GitHubAPI) error { +func runFetchWorker(db *gorm.DB, api *GitHubAPI, notifier Notifier) error { log.Println("Running fetch_worker") start := time.Now() @@ -26,14 +24,29 @@ func runFetchWorker(db *gorm.DB, api *GitHubAPI) error { }() // Run fetch job on a schedule - scheduler := cron.New() + scheduler := NewScheduler() + jobs := []*JobDefinition{ + { + Name: "daily_notifier", + Schedule: viper.GetString("daily_notifier.schedule"), + Run: func() error { + return runDailyNotification(db, notifier) + }, + }, + { + Name: "fetch_commits", + Schedule: viper.GetString("fetch_worker.schedule"), + Run: func() error { + return runFetchJob(db, api) + }, + }, + } - if _, err := scheduler.AddFunc(viper.GetString("fetch_worker.schedule"), func() { - if err := runFetchJob(db, api); err != nil { - log.Printf("Fetch job error: %v", err) + for _, job := range jobs { + log.Printf(`Scheduling %s worker to run on "%s"`, job.Name, job.Schedule) + if err := scheduler.AddJob(job); err != nil { + return err } - }); err != nil { - return errors.Wrap(err, "failed to schedule fetch job worker") } scheduler.Run() diff --git a/main.go b/main.go index 5fd5fc1..6e6a748 100644 --- a/main.go +++ b/main.go @@ -36,12 +36,15 @@ func run() error { } api := NewGitHubAPI() + notifier := NewNotifier() switch *mode { case "fetch": return runFetchJob(db, api) case "fetch_worker": - return runFetchWorker(db, api) + return runFetchWorker(db, api, notifier) + case "send_daily_notification": + return runDailyNotification(db, notifier) case "web": return runWebApp(db) default: @@ -79,6 +82,7 @@ func ViperInit() error { viper.SetDefault("port", 80) viper.SetDefault("github.search_page_depth", 5) viper.SetDefault("fetch_worker.schedule", "*/10 * * * *") + viper.SetDefault("daily_notifier.schedule", "55 23 * * *") if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { diff --git a/notifier.go b/notifier.go new file mode 100644 index 0000000..ce5a9fc --- /dev/null +++ b/notifier.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + + "github.com/spf13/viper" +) + +// Notifier encapsulates the logic and infrastructure necessary to send +// notifications to external systems. +type Notifier interface { + Notify(msg string) error +} + +// NoopNotifier simply performs a no-op instead of notifying any external system. +type NoopNotifier struct{} + +// Notify returns nil immediately. +func (n *NoopNotifier) Notify(msg string) error { + return nil +} + +// NewNotifier builds a notifier based on the current configuration, or a NoopNotifier +// if none configured. +func NewNotifier() Notifier { + token := viper.GetString("telegram.bot_token") + chatID := viper.GetString("telegram.chat_id") + + if token == "" || chatID == "" { + log.Println("🟨 No notifier configured, using NoopNotifier.") + return &NoopNotifier{} + } + + return NewTelegramNotifier(token, chatID) +} diff --git a/scheduler.go b/scheduler.go new file mode 100644 index 0000000..b7d5594 --- /dev/null +++ b/scheduler.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "log" + + "github.com/pkg/errors" + "github.com/robfig/cron/v3" +) + +// Scheduler handles running jobs on a given cron Schedule. +type Scheduler struct { + cron *cron.Cron +} + +// NewScheduler returns a new Scheduler instance. +func NewScheduler() *Scheduler { + return &Scheduler{cron.New()} +} + +// Run the cron scheduler, or no-op if already running. +func (s *Scheduler) Run() { + s.cron.Run() +} + +// JobDefinition for working with the Scheduler. +type JobDefinition struct { + Name string + Schedule string + Run func() error +} + +// AddJob adds a job to the Scheduler to be run on the given spec schedule and name. +func (s *Scheduler) AddJob(job *JobDefinition) error { + if _, err := s.cron.AddFunc(job.Schedule, func() { + if err := job.Run(); err != nil { + log.Printf("%s job error: %v", job.Name, err) + } + }); err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to schedule %s worker", job.Name)) + } + return nil +} diff --git a/telegram_notifier.go b/telegram_notifier.go new file mode 100644 index 0000000..4c232c0 --- /dev/null +++ b/telegram_notifier.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type TelegramNotifier struct { + token string + chatID string + http *http.Client +} + +func NewTelegramNotifier(token, chatID string) *TelegramNotifier { + return &TelegramNotifier{ + token: token, + chatID: chatID, + http: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +func (t *TelegramNotifier) Notify(msg string) error { + notifyURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.token) + form := url.Values{} + form.Set("text", msg) + form.Set("chat_id", t.chatID) + form.Set("parse_mode", "MarkdownV2") + + req, err := http.NewRequest(http.MethodPost, notifyURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := t.http.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("bad response %d from telegram: %s", res.StatusCode, string(body)) + } + return nil +}