Compare commits

...

3 Commits

Author SHA1 Message Date
kami 92cbdbfdd3 Merge pull request 'V-637 follow-up: telegram intake has no deploy switch, and the chat-id check cannot fail a boot' (#190) from task/646-v-637-follow-up-telegram-intake-has-no-d into master 2026-08-06 22:18:25 +02:00
claude e78b2d8992 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 <noreply@anthropic.com>
2026-08-07 00:14:42 +04:00
claude 9d58922462 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 <noreply@anthropic.com>
2026-08-07 00:14:42 +04:00
6 changed files with 116 additions and 13 deletions
+21 -3
View File
@@ -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.
+9 -1
View File
@@ -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": [
+21
View File
@@ -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,
+26
View File
@@ -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)
}
}
+24 -9
View File
@@ -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
@@ -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)
}
}
}