From 9d58922462a6399c3031e630f4195435215214d3 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 00:14:42 +0400 Subject: [PATCH 1/2] Refuse a telegram intake chat id the poller cannot match (V-646) The push half accepts an @channelusername and the intake half cannot: an inbound update names its chat by number, so an @-name matches nothing. The check lived in NewPoller, which wireTelegramIntake logs and returns from, so a box configured that way booted clean with a dead intake half and a working push half. Nothing looked broken from the chat. ValidateIntakeChatID moves the rule where config validation can reach it, the same shape validateNetScan uses. It is stricter than the old prefix test: any non-digit is refused, not just a leading @. An empty token or chat id still means telegram is not wired, because an unset ${TELEGRAM_*} expands to empty and that must not fail a box with no bot. deploy/mavend.json turns intake on. The chat id on this box is numeric. The onCallback comment claimed every path answers the callback. The fromOwner early return does not, and silence toward a stranger is correct, so the comment was what was wrong. Co-Authored-By: Claude Opus 5 --- deploy/mavend.json | 10 +++++- internal/config/config.go | 21 ++++++++++++ internal/config/config_test.go | 26 +++++++++++++++ internal/delivery/telegramsink/intake.go | 33 ++++++++++++++----- internal/delivery/telegramsink/intake_test.go | 15 +++++++++ 5 files changed, 95 insertions(+), 10 deletions(-) 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) + } + } +} From e78b2d89923d1b3a17ccfe8acee8ddf97466e800 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 00:14:42 +0400 Subject: [PATCH 2/2] the daemon table, against make build and compose (V-648) The table listed nine binaries. make build builds eleven, and mavseal and labelgen exist without targets. The running count said seven on homesrv; docker-compose.yml runs five. Adds mavgpud, mavupdate, mavseal and labelgen, and names why each absent daemon is absent: mavmaild has no mail account, mavwaked and mavenclient belong on workpc, and mavcaldav is an oversight (V-644). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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.