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`:
|
||||
|
||||
```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
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user