Files
overnight-trading-bot/internal/notify/notify.go
T
2026-06-07 21:01:40 +00:00

220 lines
4.9 KiB
Go

package notify
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"overnight-trading-bot/internal/domain"
)
type Notifier interface {
Info(ctx context.Context, msg string) error
Warn(ctx context.Context, msg string) error
Alert(ctx context.Context, msg string) error
Report(ctx context.Context, msg string) error
Close() error
}
type Noop struct{}
func (Noop) Info(context.Context, string) error { return nil }
func (Noop) Warn(context.Context, string) error { return nil }
func (Noop) Alert(context.Context, string) error { return nil }
func (Noop) Report(context.Context, string) error { return nil }
func (Noop) Close() error { return nil }
type TelegramConfig struct {
BotToken string
ChatID int64
NotifyInfo bool
NotifyWarn bool
NotifyAlert bool
NotifyReport bool
AuditSink AuditSink
}
type AuditSink interface {
InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error
}
type Telegram struct {
cfg TelegramConfig
bot *tgbotapi.BotAPI
log *slog.Logger
queue chan outbound
done chan struct{}
closed chan struct{}
}
type outbound struct {
level domain.Severity
text string
}
func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) {
if cfg.BotToken == "" || cfg.ChatID == 0 {
return Noop{}, nil
}
bot, err := tgbotapi.NewBotAPI(cfg.BotToken)
if err != nil {
return nil, err
}
t := &Telegram{
cfg: cfg,
bot: bot,
log: log,
queue: make(chan outbound, 256),
done: make(chan struct{}),
closed: make(chan struct{}),
}
go t.dispatch()
return t, nil
}
func (t *Telegram) Info(ctx context.Context, msg string) error {
if !t.cfg.NotifyInfo {
return nil
}
return t.enqueue(ctx, domain.SeverityInfo, msg, false)
}
func (t *Telegram) Warn(ctx context.Context, msg string) error {
if !t.cfg.NotifyWarn {
return nil
}
return t.enqueue(ctx, domain.SeverityWarn, msg, false)
}
func (t *Telegram) Alert(ctx context.Context, msg string) error {
if !t.cfg.NotifyAlert {
return nil
}
return t.enqueue(ctx, domain.SeverityAlert, msg, true)
}
func (t *Telegram) Report(ctx context.Context, msg string) error {
if !t.cfg.NotifyReport {
return nil
}
return t.enqueueText(ctx, domain.SeverityInfo, formatMessage("[REPORT]", msg), true)
}
func (t *Telegram) Close() error {
close(t.done)
<-t.closed
return nil
}
func (t *Telegram) enqueue(ctx context.Context, level domain.Severity, msg string, mustDeliver bool) error {
return t.enqueueText(ctx, level, formatMessage(prefix(level), msg), mustDeliver)
}
func (t *Telegram) enqueueText(ctx context.Context, level domain.Severity, text string, mustDeliver bool) error {
item := outbound{level: level, text: text}
if mustDeliver {
select {
case t.queue <- item:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
select {
case t.queue <- item:
default:
if t.log != nil {
t.log.Warn("telegram queue full; dropping non-critical notification", "level", level)
}
if t.cfg.AuditSink != nil {
_ = t.cfg.AuditSink.InsertRiskEvent(ctx, domain.RiskEvent{
TS: time.Now().UTC(),
Severity: domain.SeverityWarn,
EventType: "notification_dropped",
Message: fmt.Sprintf("telegram queue full; dropped %s notification", level),
ContextJSON: "{}",
})
}
}
return nil
}
func (t *Telegram) dispatch() {
defer close(t.closed)
for {
select {
case item := <-t.queue:
t.send(item)
case <-t.done:
for {
select {
case item := <-t.queue:
t.send(item)
default:
return
}
}
}
}
}
func (t *Telegram) send(item outbound) {
msg := tgbotapi.NewMessage(t.cfg.ChatID, item.text)
for attempt := 0; attempt < 3; attempt++ {
if _, err := t.bot.Send(msg); err != nil {
delay := telegramRetryDelay(err, attempt)
if t.log != nil {
t.log.Warn("telegram send failed", "attempt", attempt+1, "err", err, "retry_in", delay)
}
timer := time.NewTimer(delay)
select {
case <-timer.C:
case <-t.done:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
}
continue
}
return
}
}
func telegramRetryDelay(err error, attempt int) time.Duration {
var apiErr tgbotapi.Error
if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 {
return time.Duration(apiErr.RetryAfter) * time.Second
}
var apiErrPtr *tgbotapi.Error
if errors.As(err, &apiErrPtr) && apiErrPtr != nil && apiErrPtr.RetryAfter > 0 {
return time.Duration(apiErrPtr.RetryAfter) * time.Second
}
return time.Duration(attempt+1) * time.Second
}
func prefix(level domain.Severity) string {
switch level {
case domain.SeverityInfo:
return "[INFO]"
case domain.SeverityWarn:
return "[WARN]"
case domain.SeverityAlert:
return "[ALERT]"
default:
return fmt.Sprintf("[%s]", strings.ToUpper(string(level)))
}
}
func formatMessage(prefixValue, msg string) string {
return prefixValue + " " + msg
}