diff --git a/CLAUDE.md b/CLAUDE.md index 141db1f..f69a422 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`: ```sh -make build # all 9 binaries +make build # all 11 binaries make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO) make test # go test -race across ./internal/... ./cmd/... with CGO env set ``` @@ -82,13 +82,31 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test | `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. | | `mavcaldav` | CalDAV calendar sync. | | `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. | +| `mavgpud` | GPU supervisor. **Runs on workpc, not homesrv** — own unit, `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything, it reads `/health` through `llm.Pair`. | +| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. | + +Two more binaries have no Makefile target and are built with `go run` or `go build` when +they are needed. Neither is deployed. + +| Binary | Role | +|---|---| +| `mavseal` | Recovery tool. Encrypts a live tmpfs working copy back to the ciphertext file when mavend was killed before `defer st.Close()` sealed it. | +| `labelgen` | Runs the stage 0 grammars over utterances and prints JSONL, the training data for the routing heads (V-546). | Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored `deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks. -**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the -decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`). +**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.** +Count against compose, not against the table. Four of the nine daemons are absent, and each +absence has a different reason. + +`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail +account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other +three that is an oversight rather than a decision (V-644). + +**`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463, +`docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word daemon there listens to nobody. They belong on a client machine where the owner is standing. diff --git a/deploy/mavend.json b/deploy/mavend.json index 42b74fe..4a855d1 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -37,7 +37,15 @@ "This needs a matching ufw rule or the container's SYN is dropped:", " ufw allow from 192.168.240.0/20 to any port 10808 proto tcp" ], - "proxy": "socks5://192.168.240.1:10808" + "proxy": "socks5://192.168.240.1:10808", + "//intake": [ + "Read the chat as well as write to it (V-637). The poller long-polls", + "getUpdates through the same relay and accepts chat_id as the only", + "sender. Deleting this key turns inbound off again.", + "chat_id must be numeric here or the daemon refuses to start: an inbound", + "update names its chat by number, so an @-name would match nothing." + ], + "intake": true }, "//workstation": [ diff --git a/internal/config/config.go b/internal/config/config.go index 6cc0010..3f5e928 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -456,9 +456,30 @@ func (c *Config) validate() error { if err := c.validateCapture(); err != nil { return err } + if err := c.validateTelegram(); err != nil { + return err + } return nil } +// validateTelegram refuses an intake half that cannot read the chat it is +// pointed at. The push half accepts an @channelusername and the intake half +// does not, so a box configured with both boots clean, keeps pushing, and +// answers nothing — the failure is invisible from the chat. Same shape as +// validateNetScan: fail the config rather than the turn. +func (c *Config) validateTelegram() error { + if c.Telegram == nil || !c.Telegram.Intake { + return nil + } + // An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an + // empty token or chat id as telegram not being wired at all. Validating a + // block that wires nothing would fail a box that merely has no bot. + if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" { + return nil + } + return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID) +} + // DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins // over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens // a plaintext store. A configured-but-invalid key is an error (fail closed, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e315d3..75750e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -466,3 +466,29 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) { t.Errorf("Health = %q, want %q", got, want) } } + +func TestTelegramIntakeRefusesNamedChat(t *testing.T) { + // The push half accepts an @channelusername and the intake half cannot use + // one, so a box with both boots clean and answers nothing. Refuse the + // config instead. + p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven","intake":true}}`) + if _, err := Load(p); err == nil { + t.Fatal("Load succeeded for intake with an @-name chat id; want error") + } +} + +func TestTelegramNamedChatOKWithoutIntake(t *testing.T) { + // Push-only is what the @-name is for, so nothing changes for a box that + // never turned intake on. + p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven"}}`) + if _, err := Load(p); err != nil { + t.Fatalf("Load: %v", err) + } +} + +func TestTelegramIntakeAcceptsNumericChat(t *testing.T) { + p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"-1001234567890","intake":true}}`) + if _, err := Load(p); err != nil { + t.Fatalf("Load: %v", err) + } +} diff --git a/internal/delivery/telegramsink/intake.go b/internal/delivery/telegramsink/intake.go index 09e6035..e9dfb6a 100644 --- a/internal/delivery/telegramsink/intake.go +++ b/internal/delivery/telegramsink/intake.go @@ -49,6 +49,24 @@ type Poller struct { offset int64 } +// ValidateIntakeChatID refuses a chat id the intake half cannot use. The push +// half accepts @channelusername as a destination. The intake half cannot: an +// inbound update names its chat by numeric id, so an @-name would match nothing +// and the poller would read the chat and answer none of it. Config validation +// calls this, so the box refuses to boot rather than running a dead reach — +// NewPoller returning an error is too late, because the daemon is already up. +func ValidateIntakeChatID(chatID string) error { + id := strings.TrimSpace(chatID) + if id == "" { + return errors.New("telegramsink: intake needs a chat id") + } + digits := strings.TrimPrefix(id, "-") + if digits == "" || strings.TrimLeft(digits, "0123456789") != "" { + return fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", chatID) + } + return nil +} + // NewPoller builds the intake half around an already-validated sink, so the // token, the base URL and the relay are resolved in one place. turn is // required; correct may be nil, and then the reply carries no buttons. @@ -59,12 +77,8 @@ func NewPoller(s *Sink, turn Turn, correct Correct) (*Poller, error) { if turn == nil { return nil, errors.New("telegramsink: intake needs a turn handler") } - // The push half accepts @channelusername as a destination. The intake half - // cannot: an inbound update names its chat by numeric id, so an @-name would - // match nothing and the poller would read the chat and answer none of it. - // Refusing here is the difference between a boot error and a dead reach. - if strings.HasPrefix(strings.TrimSpace(s.cfg.ChatID), "@") { - return nil, fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", s.cfg.ChatID) + if err := ValidateIntakeChatID(s.cfg.ChatID); err != nil { + return nil, err } // The sink's transport already carries the relay. Only the timeout differs, // and it has to clear the long poll. @@ -164,9 +178,10 @@ func (p *Poller) onMessage(ctx context.Context, m *message) { } } -// onCallback handles a tap on a correction button. Every path answers the -// callback: telegram spins a clock on the button until it is answered, and an -// unanswered tap reads as a gesture that was dropped. +// onCallback handles a tap on a correction button. Every path from the owner +// answers the callback: telegram spins a clock on the button until it is +// answered, and an unanswered tap reads as a gesture that was dropped. A tap +// from anyone else gets silence, the same as a message from a stranger. func (p *Poller) onCallback(ctx context.Context, cb *callbackQuery) { if !p.fromOwner(cb.Message.Chat.idString()) { return diff --git a/internal/delivery/telegramsink/intake_test.go b/internal/delivery/telegramsink/intake_test.go index 780336d..9d3bf2c 100644 --- a/internal/delivery/telegramsink/intake_test.go +++ b/internal/delivery/telegramsink/intake_test.go @@ -199,3 +199,18 @@ func TestNewPollerNeedsATurn(t *testing.T) { t.Error("built a poller with no sink to answer through") } } + +// A chat id the intake half cannot match is refused before anything reads the +// chat. Config validation calls the same check, so this is the boot error. +func TestValidateIntakeChatID(t *testing.T) { + for _, ok := range []string{"123", "-1001234567890", " 42 "} { + if err := ValidateIntakeChatID(ok); err != nil { + t.Errorf("ValidateIntakeChatID(%q): %v", ok, err) + } + } + for _, bad := range []string{"", "@maven", "-", "12a", "1 2"} { + if err := ValidateIntakeChatID(bad); err == nil { + t.Errorf("ValidateIntakeChatID(%q) accepted; want error", bad) + } + } +}