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) + } + } +}