first version
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
func TestPrefix(t *testing.T) {
|
||||
tests := map[domain.Severity]string{
|
||||
domain.SeverityInfo: "[INFO]",
|
||||
domain.SeverityWarn: "[WARN]",
|
||||
domain.SeverityAlert: "[ALERT]",
|
||||
domain.Severity("alert"): "[ALERT]",
|
||||
}
|
||||
for severity, want := range tests {
|
||||
if got := prefix(severity); got != want {
|
||||
t.Fatalf("prefix(%s)=%s, want %s", severity, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReportPrefix(t *testing.T) {
|
||||
if got := formatMessage("[REPORT]", "daily"); got != "[REPORT] daily" {
|
||||
t.Fatalf("message=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramRetryDelayUsesRetryAfter(t *testing.T) {
|
||||
err := &tgbotapi.Error{
|
||||
Code: 429,
|
||||
ResponseParameters: tgbotapi.ResponseParameters{
|
||||
RetryAfter: 7,
|
||||
},
|
||||
}
|
||||
if got := telegramRetryDelay(err, 0); got != 7*time.Second {
|
||||
t.Fatalf("delay=%s, want 7s", got)
|
||||
}
|
||||
if got := telegramRetryDelay(assertErr{}, 1); got != 2*time.Second {
|
||||
t.Fatalf("fallback delay=%s, want 2s", got)
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr struct{}
|
||||
|
||||
func (assertErr) Error() string { return "boom" }
|
||||
Reference in New Issue
Block a user