Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6c3a951
feat(budget): хранение конвертов прихода, категорий и ручных лимитов
Tarasusrus Aug 23, 2026
db4ec7f
docs(adr): ADR-008 — категорийные конверты (доли прихода) + golden-сц…
Tarasusrus Aug 23, 2026
f3c8b0f
feat(safetospend): раскладка прихода по категорийным конвертам
Tarasusrus Aug 23, 2026
523940b
feat(budget): start_envelope раскладывает приход по конвертам
Tarasusrus Aug 23, 2026
bf17782
feat(budget): правка лимита конверта словами
Tarasusrus Aug 23, 2026
6cc1c46
feat(safetospend): остаток по каждому конверту (ADR-008 §8)
Tarasusrus Aug 23, 2026
b063bf8
feat(budget): перенос накоплений в новый конверт и закрытие прошлого …
Tarasusrus Aug 23, 2026
48ccdf6
feat(budget): предупреждать о пробитом конверте в момент траты (ADR-008)
Tarasusrus Aug 23, 2026
1a09953
fix(budget): правка лимита больше не теряет перенесённые накопления
Tarasusrus Aug 23, 2026
ef17e78
feat(budget): конверты в батах и рублях, дефолт — баты
Tarasusrus Aug 24, 2026
000b597
fix(budget): предупреждение о пробитом конверте не паникует и не врёт
Tarasusrus Aug 24, 2026
8fafe6c
feat(budget): утренний пуш с конвертами в существующем ReminderWorker
Tarasusrus Aug 24, 2026
6905650
fix(budget): раскладка не печатает деньги и не теряет категории (faeq…
Tarasusrus Aug 24, 2026
adff364
feat(budget): раскладка прихода в утверждённом формате (faeq.11)
Tarasusrus Aug 24, 2026
c2e46e8
feat(telegram): разметка HTML в обеих точках отправки (faeq.14)
Tarasusrus Aug 24, 2026
4772d0c
fix(budget): показ конвертов собирается и сходится с эталоном (faeq.12)
Tarasusrus Aug 24, 2026
94ce7b6
fix: дубль миграции 00018 и три дефекта ревью
Tarasusrus Aug 24, 2026
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
14 changes: 12 additions & 2 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"simpleAI/internal/budget"
"simpleAI/internal/core"
"simpleAI/internal/db"
"simpleAI/internal/envelopepush"
"simpleAI/internal/mail"
mcpserver "simpleAI/internal/mcp"
"simpleAI/internal/notify"
Expand Down Expand Up @@ -84,7 +85,7 @@ func main() {
wg.Add(1)
go func() {
defer wg.Done()
if err := runTelegram(ctx, cfg, logger, llmClient, registry, tracer, obsTracer, budgetSkill); err != nil {
if err := runTelegram(ctx, cfg, logger, llmClient, registry, tracer, obsTracer, budgetSkill, budget.NewStore(pool)); err != nil {
logger.Error("telegram stopped", "err", err)
}
}()
Expand Down Expand Up @@ -117,6 +118,13 @@ func main() {
tg := notify.NewTelegram(cfg.Telegram.Token, "")
digest := budgetskill.NewDigestProvider(budgetStore)
worker := notify.NewReminderWorker(budgetStore, tg, digest, logger)
// Утренние конверты: тело печатает тот же safe_to_spend, что отвечает на
// «сколько в конвертах» — формат живёт в скилле, воркер его не дублирует.
if skill, ok := registry.Get("safe_to_spend"); ok {
worker = worker.WithEnvelopes(envelopepush.New(budgetStore, skill))
} else {
logger.Warn("safe_to_spend skill not registered: morning envelope push disabled")
}
logger.Info("reminder worker started")
worker.Run(ctx)
}()
Expand Down Expand Up @@ -147,7 +155,7 @@ func main() {
logger.Info("shutdown complete")
}

func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, llmClient core.LLMClient, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, budgetSkill telegram.BudgetCallbackSkill) error {
func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, llmClient core.LLMClient, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, budgetSkill telegram.BudgetCallbackSkill, envelopeReminders notify.EnvelopeReminderStore) error {
if cfg.Telegram.Token == "" {
logger.Error("telegram bot token is empty, skipping")
return nil
Expand Down Expand Up @@ -226,6 +234,8 @@ func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, ll
RequestID: uuid.NewString(),
MediaDir: cfg.Telegram.MediaDir,
Registry: registry,

EnvelopeReminders: envelopeReminders,
})
if err != nil {
logger.Error("telegram update error", "err", err)
Expand Down
300 changes: 300 additions & 0 deletions docs/adr/008-envelope-shares.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions evals/golden_set.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,16 @@
{"id":"r040","input":"запланируй трату 15600 на визу","expected":{"skill":"budget","action":"add_planned_expense"},"tags":["add_planned_expense","planned","ru"]}
{"id":"r041","input":"запомни приход 127000 на 2 недели","expected":{"skill":"budget","action":"start_envelope"},"tags":["start_envelope","envelope","ru"]}
{"id":"r042","input":"сколько свободных денег осталось?","expected":{"skill":"safe_to_spend","action":""},"tags":["remaining","envelope","ru"]}
# ADR-008 (конверты-доли). Кейсы заведены ДО кода: скиллов/actions ещё нет,
# до задач 4/6/8 эпика simpleAI-faeq они краснеют — это ожидаемый TDD-красный.
# Механизма skip у харнеса нет; срез снимается тегом: -tag envelope_shares.
{"id":"r043","input":"пришло 127000, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","ru"],"notes":"ADR-008 §1 — раскладка прихода по категорийным долям; делится FreeAfterObligations, не сырой приход"}
{"id":"r044","input":"сколько в конвертах","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","read_only","ru","pending_impl"],"notes":"ADR-008 — read-only статус долей, ничего не пишет"}
{"id":"r045","input":"сколько осталось на еду","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","read_only","ru"],"notes":"ADR-008 §8 — остаток доли производный: allocated + carried_in − факт"}
{"id":"r046","input":"на еду хватит 15000","expected":{"skill":"budget","action":"set_share_limit"},"tags":["envelope_shares","share_override","ru","regression"],"notes":"ADR-008 §2 — ручная правка лимита доли (override), НЕ add_expense: сумма есть, но траты не было"}
{"id":"r047","input":"купил еды на 3000","expected":{"skill":"budget","action":"add_expense"},"tags":["envelope_shares","past_tense","with_amount","ru","regression"],"notes":"контраст к r046: разграничение триггеров уже ломалось (simpleAI-399, simpleAI-q49). Должен проходить и ДО реализации конвертов"}
{"id":"r048","input":"покажи конверты в рублях","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","display_currency","read_only","ru"],"notes":"валюта показа: конверты по умолчанию в батах, «в рублях» — просьба перевести показ (display_currency=RUB), скилл прежний"}
{"id":"r049","input":"на еду хватит 5000 бат","expected":{"skill":"budget","action":"set_share_limit"},"tags":["envelope_shares","share_override","display_currency","ru"],"notes":"лимит словами в батах: тот же set_share_limit, что и r046 в рублях; валюта суммы идёт в currency=THB"}
{"id":"r050","input":"пришло 127000 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","acceptance","ru"],"notes":"simpleAI-faeq.10 — эталонный кейс приёмки: 127к делятся на ВСЕ расходы периода, включая регулярные платежи отдельными строками; повтор той же фразы обязан давать тот же результат (carried_in не растёт)"}
{"id":"r051","input":"пришло 10 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","small_income","ru"],"notes":"simpleAI-faeq.11 §5 — малый приход не ломает раскладку: дневной лимит считается от гибких конвертов, приход в формуле не участвует"}
{"id":"r052","input":"сколько можно тратить в день","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","daily_limit","read_only","ru"],"notes":"simpleAI-faeq.11 §5 — дневной лимит = гибкие конверты / оставшиеся дни, пересчитывается после каждой траты; read-only"}
104 changes: 87 additions & 17 deletions internal/adapters/telegram/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"

botformat "simpleAI/internal/bot/format"
"simpleAI/internal/core"
)

Expand Down Expand Up @@ -96,24 +97,64 @@ func (a *Adapter) Updates(ctx context.Context) (<-chan core.Update, error) {
}

func (a *Adapter) Send(ctx context.Context, chatID int64, text string) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
msg := tgbotapi.NewMessage(chatID, text)
_, err := a.bot.Send(msg)
return err
return a.sendMessage(ctx, chatID, 0, text, nil)
}

func (a *Adapter) Reply(ctx context.Context, chatID int64, replyTo int, text string) error {
return a.sendMessage(ctx, chatID, replyTo, text, nil)
}

// sendMessage — единственная точка отправки текста. Здесь и только здесь
// включается parse_mode: разметка обязана быть централизованной, иначе один
// забытый вызов роняет формат (или, хуже, отправку) уже после ревью.
//
// Порядок такой:
// 1. текст скилла переводится в HTML (экранируется ВСЁ, кроме моноблока и
// **жирного** — см. internal/bot/format);
// 2. режется на куски в пределах лимита Telegram;
// 3. если Telegram всё же ответил ошибкой на размеченный вариант — тот же
// текст уходит простым. Молчание бота хуже, чем звёздочки в чате.
//
// Клавиатура вешается на последний кусок: выше неё должен лежать весь текст,
// к которому она относится.
func (a *Adapter) sendMessage(ctx context.Context, chatID int64, replyTo int, text string, rows [][]core.Button) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
msg := tgbotapi.NewMessage(chatID, text)
msg.ReplyToMessageID = replyTo
_, err := a.bot.Send(msg)
chunks := botformat.MessagesHTML(text)
if len(chunks) == 0 {
return nil
}
sent, err := a.deliver(chatID, replyTo, chunks, tgbotapi.ModeHTML, rows)
if err == nil {
return nil
}
// Фоллбэк — только если не ушло НИЧЕГО. Иначе повтор простым текстом
// продублировал бы в чате уже доставленные куски.
if sent > 0 {
return err
}
_, err = a.deliver(chatID, replyTo, botformat.MessagesPlain(text), "", rows)
return err
}

func (a *Adapter) deliver(chatID int64, replyTo int, chunks []string, parseMode string, rows [][]core.Button) (int, error) {
for i, chunk := range chunks {
msg := tgbotapi.NewMessage(chatID, chunk)
msg.ParseMode = parseMode
if replyTo != 0 && i == 0 {
msg.ReplyToMessageID = replyTo
}
if len(rows) > 0 && i == len(chunks)-1 {
msg.ReplyMarkup = toInlineKeyboard(rows)
}
if _, err := a.bot.Send(msg); err != nil {
return i, err
}
}
return len(chunks), nil
}

func (a *Adapter) SendTyping(ctx context.Context, chatID int64) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
Expand All @@ -126,21 +167,50 @@ func (a *Adapter) SendWithButtons(ctx context.Context, chatID int64, text string
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
msg := tgbotapi.NewMessage(chatID, text)
msg.ReplyMarkup = toInlineKeyboard(rows)
_, err := a.bot.Send(msg)
return err
return a.sendMessage(ctx, chatID, 0, text, rows)
}

func (a *Adapter) EditWithButtons(ctx context.Context, chatID int64, messageID int, text string, rows [][]core.Button) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
edit := tgbotapi.NewEditMessageText(chatID, messageID, text)
// Правку резать некуда — сообщение одно. Слишком длинный текст всё же
// усекается по лимиту: 400 «message is too long» стёр бы правку целиком.
edit := tgbotapi.NewEditMessageText(chatID, messageID, firstChunk(botformat.MessagesHTML(text)))
edit.ParseMode = tgbotapi.ModeHTML
edit.ReplyMarkup = editKeyboard(rows)
if _, err := a.bot.Send(edit); err != nil {
// Тот же фоллбэк, что и у отправки: разметка не должна стирать содержимое.
plain := tgbotapi.NewEditMessageText(chatID, messageID, firstChunk(botformat.MessagesPlain(text)))
plain.ReplyMarkup = editKeyboard(rows)
_, err := a.bot.Send(plain)
return err
}
return nil
}

// editKeyboard решает, что приложить к правке сообщения:
// - rows == nil — клавиатуру не трогаем (у сообщения её и не было);
// - rows пуст, но не nil — ПУСТАЯ клавиатура, то есть «снять кнопки»;
// ровно так вызывающий код гасит инлайн-кнопки после обработки callback-а,
// и молчаливый пропуск reply_markup оставил бы их живыми;
// - иначе — обычная клавиатура.
func editKeyboard(rows [][]core.Button) *tgbotapi.InlineKeyboardMarkup {
if rows == nil {
return nil
}
kb := toInlineKeyboard(rows)
edit.ReplyMarkup = &kb
_, err := a.bot.Send(edit)
return err
if kb.InlineKeyboard == nil {
kb.InlineKeyboard = [][]tgbotapi.InlineKeyboardButton{}
}
return &kb
}

func firstChunk(chunks []string) string {
if len(chunks) == 0 {
return ""
}
return chunks[0]
}

func (a *Adapter) AnswerCallback(ctx context.Context, callbackID string) error {
Expand Down
Loading
Loading