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
This commit was merged in pull request #190.
This commit is contained in:
@@ -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`:
|
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||||
|
|
||||||
```sh
|
```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 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
|
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. |
|
| `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. |
|
| `mavcaldav` | CalDAV calendar sync. |
|
||||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
| `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
|
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
|
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.
|
`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
|
**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.**
|
||||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
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
|
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.
|
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -37,7 +37,15 @@
|
|||||||
"This needs a matching ufw rule or the container's SYN is dropped:",
|
"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"
|
" 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": [
|
"//workstation": [
|
||||||
|
|||||||
@@ -456,9 +456,30 @@ func (c *Config) validate() error {
|
|||||||
if err := c.validateCapture(); err != nil {
|
if err := c.validateCapture(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.validateTelegram(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
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
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
// 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,
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
||||||
|
|||||||
@@ -466,3 +466,29 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
|||||||
t.Errorf("Health = %q, want %q", got, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,24 @@ type Poller struct {
|
|||||||
offset int64
|
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
|
// 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
|
// 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.
|
// 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 {
|
if turn == nil {
|
||||||
return nil, errors.New("telegramsink: intake needs a turn handler")
|
return nil, errors.New("telegramsink: intake needs a turn handler")
|
||||||
}
|
}
|
||||||
// The push half accepts @channelusername as a destination. The intake half
|
if err := ValidateIntakeChatID(s.cfg.ChatID); err != nil {
|
||||||
// cannot: an inbound update names its chat by numeric id, so an @-name would
|
return nil, err
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
// The sink's transport already carries the relay. Only the timeout differs,
|
// The sink's transport already carries the relay. Only the timeout differs,
|
||||||
// and it has to clear the long poll.
|
// 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
|
// onCallback handles a tap on a correction button. Every path from the owner
|
||||||
// callback: telegram spins a clock on the button until it is answered, and an
|
// answers the callback: telegram spins a clock on the button until it is
|
||||||
// unanswered tap reads as a gesture that was dropped.
|
// 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) {
|
func (p *Poller) onCallback(ctx context.Context, cb *callbackQuery) {
|
||||||
if !p.fromOwner(cb.Message.Chat.idString()) {
|
if !p.fromOwner(cb.Message.Chat.idString()) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -199,3 +199,18 @@ func TestNewPollerNeedsATurn(t *testing.T) {
|
|||||||
t.Error("built a poller with no sink to answer through")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user