da647e87d0
The trust boundary is zenmoney, not maven — they already hold his bank sessions. So the poller reads /v8/diff/ and writes totals as facts(kind=env, source=poll:zenmoney); core reads those back when he asks and never sees the token. internal/zenmoney sums transactions per currency over a window, skipping tombstoned rows and transfers between his own accounts, and refuses to encode a summary built from zero transactions. That refusal is the whole design: a failed or empty read writes nothing and leaves the last good total alone, because a zero recited as fact is worse than silence. No currency conversion either — a figure he can check against his bank beats one he cannot. Off unless configured, and the token is read from a FILE rather than a flag so it never lands in `ps`, in docker-compose.yml, or in shell history. Nothing about the money is search input, no tick rule reads the keys, and the log lines name keys, never figures. The live-credential half is BLOCKED: there is no zenmoney account or token here, so everything is verified against a recorded diff fixture.
79 lines
2.7 KiB
Go
79 lines
2.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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) {
|
|
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
|
if 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
|
|
}
|
|
reply := val.FormatRU(phrase)
|
|
if reply == "" {
|
|
return "по тратам пока нечего сказать.", true
|
|
}
|
|
// A stale fact is reported as stale rather than spoken as today's number.
|
|
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
|
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
|
}
|
|
return reply, true
|
|
}
|
|
|
|
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
|
func isNoFactErr(err error) bool {
|
|
for e := err; e != nil; {
|
|
if e == ipc.ErrNoFact {
|
|
return true
|
|
}
|
|
u, ok := e.(interface{ Unwrap() error })
|
|
if !ok {
|
|
return false
|
|
}
|
|
e = u.Unwrap()
|
|
}
|
|
return false
|
|
}
|