Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ func main() {
defer wg.Done()
budgetStore := budget.NewStore(pool)
tg := notify.NewTelegram(cfg.Telegram.Token, "")
worker := notify.NewReminderWorker(budgetStore, tg, logger)
digest := budgetskill.NewDigestProvider(budgetStore)
worker := notify.NewReminderWorker(budgetStore, tg, digest, logger)
logger.Info("reminder worker started")
worker.Run(ctx)
}()
Expand Down
22 changes: 20 additions & 2 deletions internal/notify/reminder_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,25 @@ type ReminderSender interface {
SendToChatID(ctx context.Context, chatID int64, text string) error
}

// DigestSource строит дайджест трат за вчера для напоминания.
// Реализуется в budget/skills (DigestProvider); notify знает только контракт.
// Пустая строка = показывать нечего. Проводка в воркер — задача simpleAI-c2ud.
type DigestSource interface {
YesterdayDigest(ctx context.Context, chatID int64, loc *time.Location) (string, error)
}

// ReminderWorker каждую минуту проверяет, кому нужно отправить напоминание.
type ReminderWorker struct {
store ReminderStore
sender ReminderSender
digest DigestSource
logger *slog.Logger
}

// NewReminderWorker создаёт воркер напоминаний.
func NewReminderWorker(store ReminderStore, sender ReminderSender, logger *slog.Logger) *ReminderWorker {
return &ReminderWorker{store: store, sender: sender, logger: logger}
// digest опционален (nil допустим) — без него шлётся только напоминание.
func NewReminderWorker(store ReminderStore, sender ReminderSender, digest DigestSource, logger *slog.Logger) *ReminderWorker {
return &ReminderWorker{store: store, sender: sender, digest: digest, logger: logger}
}

// Run запускает цикл опроса. Блокируется до отмены ctx.
Expand Down Expand Up @@ -60,6 +69,15 @@ func (w *ReminderWorker) check(ctx context.Context, now time.Time) {
local := now.In(loc)
if local.Hour() == r.NotifyHour && local.Minute() == r.NotifyMinute {
text := "👋 Привет! Не забудь внести сегодняшние покупки и траты."
// Дайджест за вчера — non-fatal: ошибка/пусто не блокирует напоминание.
if w.digest != nil {
d, err := w.digest.YesterdayDigest(ctx, r.ChatID, loc)
if err != nil {
w.logger.Warn("reminder worker: digest failed", "chat_id", r.ChatID, "err", err)
} else if d != "" {
text += "\n\n" + d
}
}
if err := w.sender.SendToChatID(ctx, r.ChatID, text); err != nil {
w.logger.Error("reminder worker: send failed", "chat_id", r.ChatID, "err", err)
}
Expand Down
116 changes: 116 additions & 0 deletions internal/notify/reminder_worker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package notify

import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"

"simpleAI/internal/budget"
)

func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

type stubReminderStore struct {
reminders []budget.Reminder
}

func (s *stubReminderStore) ListActiveReminders(_ context.Context) ([]budget.Reminder, error) {
return s.reminders, nil
}

type captureSender struct {
sent []string
}

func (c *captureSender) SendToChatID(_ context.Context, _ int64, text string) error {
c.sent = append(c.sent, text)
return nil
}

type stubDigest struct {
out string
err error
}

func (s stubDigest) YesterdayDigest(_ context.Context, _ int64, _ *time.Location) (string, error) {
return s.out, s.err
}

// now, попадающее в окно напоминания (09:00 UTC).
func windowNow() time.Time { return time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC) }

func workerWith(digest DigestSource) (*ReminderWorker, *captureSender) {
store := &stubReminderStore{reminders: []budget.Reminder{
{ChatID: 1, Enabled: true, NotifyHour: 9, NotifyMinute: 0, Timezone: "UTC"},
}}
sender := &captureSender{}
return NewReminderWorker(store, sender, digest, discardLogger()), sender
}

// Ошибка дайджеста не блокирует доставку: напоминание уходит без дайджеста.
func TestCheck_DigestError_StillSendsReminder(t *testing.T) {
w, sender := workerWith(stubDigest{err: errors.New("boom")})
w.check(context.Background(), windowNow())

if len(sender.sent) != 1 {
t.Fatalf("want 1 message sent, got %d", len(sender.sent))
}
if !strings.Contains(sender.sent[0], "Не забудь") {
t.Fatalf("reminder text missing: %q", sender.sent[0])
}
if strings.Contains(sender.sent[0], "Вчера потрачено") {
t.Fatalf("digest must be absent on error: %q", sender.sent[0])
}
}

// Непустой дайджест добавляется к напоминанию.
func TestCheck_DigestNonEmpty_Appended(t *testing.T) {
w, sender := workerWith(stubDigest{out: "💸 Вчера потрачено: ~1000 ฿"})
w.check(context.Background(), windowNow())

if len(sender.sent) != 1 {
t.Fatalf("want 1 message, got %d", len(sender.sent))
}
if !strings.Contains(sender.sent[0], "Не забудь") || !strings.Contains(sender.sent[0], "1000 ฿") {
t.Fatalf("want reminder + digest, got %q", sender.sent[0])
}
}

// Пустой дайджест → только напоминание, без висящего переноса строки.
func TestCheck_DigestEmpty_NoTrailingNewline(t *testing.T) {
w, sender := workerWith(stubDigest{out: ""})
w.check(context.Background(), windowNow())

if len(sender.sent) != 1 {
t.Fatalf("want 1 message, got %d", len(sender.sent))
}
if strings.HasSuffix(sender.sent[0], "\n") {
t.Fatalf("empty digest must not append newline: %q", sender.sent[0])
}
}

// nil digest (источник не сконфигурирован) → только напоминание.
func TestCheck_NilDigest_SendsReminder(t *testing.T) {
w, sender := workerWith(nil)
w.check(context.Background(), windowNow())

if len(sender.sent) != 1 {
t.Fatalf("want 1 message, got %d", len(sender.sent))
}
}

// Вне окна напоминания ничего не отправляется.
func TestCheck_OutsideWindow_NoSend(t *testing.T) {
w, sender := workerWith(stubDigest{out: "x"})
w.check(context.Background(), time.Date(2026, 6, 29, 10, 30, 0, 0, time.UTC))

if len(sender.sent) != 0 {
t.Fatalf("want no messages outside window, got %d", len(sender.sent))
}
}
91 changes: 91 additions & 0 deletions internal/skills/budget/digest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package budgetskill

import (
"context"
"fmt"
"time"

"simpleAI/internal/budget"
)

// nowFunc — источник текущего времени, подменяется в тестах для проверки
// границы таймзоны.
var nowFunc = time.Now

// digestStore — узкий контракт стора для дайджеста (тестируемость).
// *budget.Store его удовлетворяет.
type digestStore interface {
GetSummary(ctx context.Context, p budget.Period) (*budget.Summary, error)
GetExchangeRates(ctx context.Context) (map[string]float64, error)
}

// DigestProvider строит текстовый дайджест трат за вчера для напоминаний.
// Реализует notify.DigestSource структурно (метод YesterdayDigest), что держит
// пакет notify развязанным: рендер и конверсия валют живут здесь (budget/skills),
// notify знает только сигнатуру интерфейса.
type DigestProvider struct {
store digestStore
}

// NewDigestProvider создаёт провайдер дайджеста.
func NewDigestProvider(store digestStore) *DigestProvider {
return &DigestProvider{store: store}
}

// YesterdayDigest возвращает однострочный дайджест трат за вчера в THB-экв.
//
// chatID НЕ участвует в выборке данных: бюджет — глобальный single-household
// ledger (budget_transaction без chat_id, store.go GetSummary). chatID и loc
// определяют только календарный день «вчера» в таймзоне получателя.
//
// Пустой день (нет трат) → "" (вызывающий не показывает блок).
func (d *DigestProvider) YesterdayDigest(ctx context.Context, chatID int64, loc *time.Location) (string, error) {
if loc == nil {
loc = time.UTC
}
_ = chatID // global ledger: chatID только для адресации, не фильтр данных

// Календарный день «вчера» берём в loc, диапазон якорим UTC-midnight —
// transaction_date это DATE, важна только дата (консистентно с query.go).
y := nowFunc().In(loc).AddDate(0, 0, -1)
p := budget.Period{
From: time.Date(y.Year(), y.Month(), y.Day(), 0, 0, 0, 0, time.UTC),
To: time.Date(y.Year(), y.Month(), y.Day(), 23, 59, 59, 0, time.UTC),
}

sum, err := d.store.GetSummary(ctx, p)
if err != nil {
return "", fmt.Errorf("digest: get summary: %w", err)
}

totalTHB := summaryTotalTHB(sum, d.rates(ctx))
if totalTHB <= 0 {
return "", nil
}
return fmt.Sprintf("💸 Вчера потрачено: ~%.0f ฿", totalTHB), nil
}

// rates возвращает курсы из стора с фолбэком на rubRates (как в callback.go).
func (d *DigestProvider) rates(ctx context.Context) map[string]float64 {
rates, err := d.store.GetExchangeRates(ctx)
if err != nil || len(rates) == 0 {
return rubRates
}
for k, v := range rubRates {
if _, ok := rates[k]; !ok {
rates[k] = v
}
}
return rates
}

// summaryTotalTHB сводит расходы сводки к THB-экв.
// Все валюты → RUB (summaryTotalRUB), затем делёж на курс THB→RUB.
// Курс THB берётся из rates с фолбэком на rubRates; защита от нуля.
func summaryTotalTHB(s *budget.Summary, rates map[string]float64) float64 {
thbRate := rates["THB"]
if thbRate <= 0 {
thbRate = rubRates["THB"]
}
return summaryTotalRUB(s, rates) / thbRate
}
126 changes: 126 additions & 0 deletions internal/skills/budget/digest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package budgetskill

import (
"context"
"strings"
"testing"
"time"

"simpleAI/internal/budget"
)

// mockDigestStore — фейк digestStore: фиксированная сводка + захват Period.
type mockDigestStore struct {
summary *budget.Summary
rates map[string]float64
gotPeriod budget.Period
summaryErr error
}

func (m *mockDigestStore) GetSummary(_ context.Context, p budget.Period) (*budget.Summary, error) {
m.gotPeriod = p
if m.summaryErr != nil {
return nil, m.summaryErr
}
return m.summary, nil
}

func (m *mockDigestStore) GetExchangeRates(_ context.Context) (map[string]float64, error) {
return m.rates, nil
}

func expenseSummary(groups ...budget.CurrencyGroup) *budget.Summary {
return &budget.Summary{Currencies: groups}
}

// withFixedNow подменяет nowFunc на время для проверки и восстанавливает.
func withFixedNow(t *testing.T, now time.Time) {
t.Helper()
prev := nowFunc
nowFunc = func() time.Time { return now }
t.Cleanup(func() { nowFunc = prev })
}

// Непустой день в RUB → строка с символом ฿ и THB-эквивалентом (RUB/2.5).
func TestYesterdayDigest_NonEmpty(t *testing.T) {
store := &mockDigestStore{
summary: expenseSummary(budget.CurrencyGroup{Currency: "RUB", TotalExpense: 2500}),
rates: map[string]float64{"RUB": 1, "THB": 2.5},
}
out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
// 2500 RUB / 2.5 = 1000 ฿
if !strings.Contains(out, "฿") || !strings.Contains(out, "1000") {
t.Fatalf("want THB total 1000 ฿, got %q", out)
}
}

// Пустой день (нет трат) → "".
func TestYesterdayDigest_Empty(t *testing.T) {
store := &mockDigestStore{
summary: expenseSummary(),
rates: map[string]float64{"RUB": 1, "THB": 2.5},
}
out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != "" {
t.Fatalf("empty day must yield \"\", got %q", out)
}
}

// Мультивалюта сводится к THB-экв: RUB напрямую, THB как есть, USD через RUB.
func TestYesterdayDigest_MultiCurrency(t *testing.T) {
store := &mockDigestStore{
summary: expenseSummary(
budget.CurrencyGroup{Currency: "RUB", TotalExpense: 250}, // /2.5 = 100 ฿
budget.CurrencyGroup{Currency: "THB", TotalExpense: 300}, // = 300 ฿
budget.CurrencyGroup{Currency: "USD", TotalExpense: 10}, // *82/2.5 = 328 ฿
),
rates: map[string]float64{"RUB": 1, "THB": 2.5, "USD": 82},
}
out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
// 100 + 300 + 328 = 728 ฿
if !strings.Contains(out, "728") {
t.Fatalf("want 728 ฿, got %q", out)
}
}

// Граница таймзоны: «вчера» считается в loc, не в UTC.
func TestYesterdayDigest_TimezoneBoundary(t *testing.T) {
// 18:00 UTC 2026-06-29 == 01:00 2026-06-30 в Asia/Bangkok (+7).
withFixedNow(t, time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC))
bangkok, err := time.LoadLocation("Asia/Bangkok")
if err != nil {
t.Fatalf("load tz: %v", err)
}
store := &mockDigestStore{
summary: expenseSummary(budget.CurrencyGroup{Currency: "THB", TotalExpense: 100}),
rates: map[string]float64{"RUB": 1, "THB": 2.5},
}
if _, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, bangkok); err != nil {
t.Fatalf("unexpected err: %v", err)
}
// В Бангкоке сейчас 30 июня → вчера = 29 июня. В UTC было бы 28 июня.
if got := store.gotPeriod.From.Day(); got != 29 {
t.Fatalf("yesterday in loc must be day 29, got day %d (%s)", got, store.gotPeriod.From)
}
}

// Пустая строка при ошибке GetSummary (вызывающий деградирует non-fatal).
func TestYesterdayDigest_SummaryError(t *testing.T) {
store := &mockDigestStore{summaryErr: context.DeadlineExceeded, rates: map[string]float64{"THB": 2.5}}
out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC)
if err == nil {
t.Fatal("want error from GetSummary")
}
if out != "" {
t.Fatalf("error must yield empty string, got %q", out)
}
}
Loading