package main import ( "context" "errors" "log" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/zenmoney" ) // Money questions (Vikunja #125). // // This is the whole read side: mavpoll holds the zenmoney token and writes // facts(kind=env, source=poll:zenmoney); core reads them back when he asks. // Core never sees the token, never calls zenmoney, and has no rule on these // keys — a total is never a reason for Maven to speak first. Maven is not a // nag, least of all about his money. // // Nothing here can reach the external search capability: the figures are read // from the store and rendered locally, and his financial data is never search // input. // queryMoney — "сколько я потратил сегодня?", "покажи мои траты". // // Answers only from the latest fact the poller wrote. Three honest outcomes and // no fourth: the figure, "the fact is old and here is its date", or "money // tracking is not connected". It never computes, estimates or rounds a total of // its own — an invented number about his money is the worst thing this could do. func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) { q, ok := router.ParseMoneyQuery(t.dec.Utterance) if !ok { return "", false } if q.Window == router.MoneyUnsupported { // Two windows are stored and no others. Answering "сколько я потратил // вчера?" with the month-to-date total answers a different question // with a real number, which is the shape of a lie he cannot spot. return "я храню только сегодняшние траты и за этот месяц.", true } key, phrase := zenmoney.KeySpentMonth, "в этом месяце" if q.Window == router.MoneyToday { key, phrase = zenmoney.KeySpentToday, "сегодня" } fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source) if err != nil { // No fact at all is the normal state when the capability is off. Claim // the turn anyway: falling through to recall would answer a question // about money with whatever note happens to be nearest. if !isNoFactErr(err) { log.Printf("voice: money fact: %v", err) } return "я не отслеживаю траты — не подключено.", true } val, err := zenmoney.ParseFactValue(fact.Value) if err != nil { log.Printf("voice: money fact: decode: %v", err) return "не получилось прочитать траты.", true } now := h.now() if q.Window == router.MoneyToday && !val.CoversDay(now) { // The day window rolled over and the poller had nothing to write, // because he has not spent anything yet today. The fact is fresh by ts // and covers yesterday, so no staleness check can catch it — only the // window stamp inside the value can. return "сегодня пока ничего не вижу.", true } reply := val.FormatRU(phrase) if q.Income { reply = val.FormatIncomeRU(phrase) } if reply == "" { return "по тратам пока нечего сказать.", true } // A stale fact is reported as stale rather than spoken as today's number. // The age is measured from when the figure was last READ, not from when it // last changed: a month with no spending in it does not go stale. asOf := val.AsOf if asOf.IsZero() { asOf = fact.Ts } if now.Sub(asOf) > zenmoney.StaleAfter { return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true } return reply, true } // isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The // hand-rolled loop this replaces missed any error implementing Is(error) bool. func isNoFactErr(err error) bool { return errors.Is(err, ipc.ErrNoFact) }