crypto_alert_bot/internal/service/alerter/alerter.go
2026-02-25 23:52:21 +03:00

131 lines
3 KiB
Go

package alerter
import (
"context"
"fmt"
"log/slog"
"time"
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities"
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider"
"github.com/shopspring/decimal"
)
type Notifier interface {
NotifyAlert(ctx context.Context, userID entities.UserID, alert *entities.Alert, currentPrice decimal.Decimal) error
}
type Storage interface {
AllActiveAlerts(ctx context.Context) ([]entities.Alert, error)
DisableAlert(ctx context.Context, id entities.AlertID) error
}
type Alerter struct {
log *slog.Logger
cache *alertsCache
priceProvider provider.Provider
notifier Notifier
storage Storage
}
const interval = time.Minute
func New(log *slog.Logger, priceProvider provider.Provider, notifier Notifier, storage Storage) *Alerter {
return &Alerter{
log: log,
cache: newCache(),
priceProvider: priceProvider,
notifier: notifier,
storage: storage,
}
}
func (a *Alerter) LoadAlerts(ctx context.Context) error {
alerts, err := a.storage.AllActiveAlerts(ctx)
if err != nil {
return fmt.Errorf("failed to load alerts: %w", err)
}
for i := range alerts {
a.cache.Add(&alerts[i])
}
a.log.Info("alerts loaded", "count", len(alerts))
return nil
}
func (a *Alerter) AddAlert(alert *entities.Alert) {
a.cache.Add(alert)
}
func (a *Alerter) RemoveAlert(id entities.AlertID) {
a.cache.Remove(id)
}
func (a *Alerter) Run(ctx context.Context) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
a.log.Info("start checking alerts")
if err := a.checkAlerts(ctx); err != nil {
a.log.Error("failed to check alerts", "err", err)
continue
}
a.log.Info("alerts checked")
}
}()
}
// TODO: parallel checking for different instruments.
func (a *Alerter) checkAlerts(ctx context.Context) error {
instruments := a.cache.Instruments()
for _, instrument := range instruments {
price, err := a.priceProvider.Price(ctx, instrument)
if err != nil {
a.log.Error("failed to get price", "instrument", instrument.ID, "err", err)
continue
}
alerts := a.cache.AlertsByInstrument(instrument.ID)
for _, alert := range alerts {
switch alert.Condition {
case entities.AlertConditionAbove:
if price.Ask.GreaterThanOrEqual(alert.Price) {
a.triggerAlert(ctx, alert, price.Ask)
}
case entities.AlertConditionBelow:
if price.Bid.LessThanOrEqual(alert.Price) {
a.triggerAlert(ctx, alert, price.Bid)
}
}
}
}
return nil
}
func (a *Alerter) triggerAlert(ctx context.Context, alert *entities.Alert, currentPrice decimal.Decimal) {
if err := a.notifier.NotifyAlert(ctx, alert.UserID, alert, currentPrice); err != nil {
a.log.Error("failed to notify alert", "alert_id", alert.ID, "err", err)
return
}
a.cache.Remove(alert.ID)
if err := a.storage.DisableAlert(ctx, alert.ID); err != nil {
a.log.Error("failed to disable alert in db", "alert_id", alert.ID, "err", err)
}
}