From d731a7429d705455cec20d4fe566fa193bc41c0f Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Sun, 23 Aug 2026 17:36:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(config):=20=D0=BE=D1=82=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20Telegram-=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20(telegram.enabled)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TelegramCfg.Enabled (дефолт true); при false канал не создаётся и не крепится в Router, long-poll не стартует — нет сетевых вызовов к api.telegram.org - Validate требует token/chat_id только при enabled - Load пресетит enabled=true до unmarshal (applyDefaults для bool не различает явный false) - тесты, README и config.yaml.example обновлены --- .serena/memories/core.md | 1 + README.md | 5 ++-- config.yaml.example | 7 +++-- internal/app/app.go | 54 +++++++++++++++++++++------------- internal/config/config_test.go | 37 ++++++++++++++++++++++- internal/config/load.go | 4 +++ internal/config/types.go | 20 ++++++++----- 7 files changed, 95 insertions(+), 33 deletions(-) diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 9ba5482..4883252 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -40,6 +40,7 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do - **gitops (worker):** worktree-режим; feature-ветка `feat/` от origin/main; push через http.extraHeader, токен Bearer. - **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd. - **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-/` (не `latest/`); Verify сверяет контрольную сумму бинаря против `.sha256` той же версии. **Perf:** `ResolveLatest` проверяет наличие бинаря версии **HEAD-пробой без скачивания тела** (405/501 → fallback `GET Range: bytes=0-0`), сортировка версий по id убыв.; один общий `http.Client` (keep-alive). Ошибка U4 — только при несовпадении суммы (пустой/отсутствующий `.sha256` пропускает проверку — M1-известное замечание). +- **Telegram toggle:** `telegram.enabled` (дефолт true, preset в `Load` до unmarshal, т.к. `applyDefaults` для bool не различает явный false). При `false` канал не создаётся/не крепится в Router и long-poll не стартует — сетевых вызовов к api.telegram.org нет; token/chat_id не валидируются. ## opencode (v2 HTTP API, >= 1.18.18) diff --git a/README.md b/README.md index 9a20060..fb509c8 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ make run # build + ./ratatoskr -config config.yaml ```yaml telegram: - token: "..." # TG_TOKEN - chat_id: "..." # TG_CHAT_ID + enabled: true # false — отключить Telegram (без сетевых вызовов к api.telegram.org) + token: "..." # TG_TOKEN (обязательно, если enabled) + chat_id: "..." # TG_CHAT_ID (обязательно, если enabled) opencode: bin: "opencode" config_dir: "./agents" # каталог, куда распаковываются встроенные агенты diff --git a/config.yaml.example b/config.yaml.example index 0ca06b7..e6174a9 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,10 +1,11 @@ # ratatoskr-go пример конфигурации. # Можно не указывать поля, у которых есть дефолт (см. internal/config/types.go). -# Обязательно: telegram.token + telegram.chat_id +# Если telegram.enabled: true (дефолт) — обязательно: telegram.token + telegram.chat_id. telegram: - token: "${TG_TOKEN}" # токен Telegram бота (обязательно) - chat_id: "${TG_CHAT_ID}" # ID чата (обязательно) + enabled: true # false — полностью отключить Telegram (без сетевых вызовов) + token: "${TG_TOKEN}" # токен Telegram бота (обязательно, если enabled) + chat_id: "${TG_CHAT_ID}" # ID чата (обязательно, если enabled) # Всё ниже — опционально, показаны дефолты: # opencode: diff --git a/internal/app/app.go b/internal/app/app.go index aae18e7..3d0f200 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -63,7 +63,7 @@ type App struct { CoreCtx *core.Core Worker *worker.Worker Updater *update.Updater - tg *telegram.Channel // сохранена для Run + tg *telegram.Channel // сохранена для Run (nil, если telegram.enabled=false) pool *opencode.Pool // пул opencode serve-серверов (API-режим) // Events — доменная шина UI; LogEvents — шина логов (панель «Логи»). @@ -224,12 +224,14 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) { } } - // Telegram-канал - tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration()) - a.tg = tg - if err := router.Attach(tg); err != nil { - store.Close() - return nil, fmt.Errorf("attach telegram: %w", err) + // Telegram-канал (если включён; иначе сетевые вызовы к Telegram не идут) + if cfg.Telegram.Enabled { + tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration()) + a.tg = tg + if err := router.Attach(tg); err != nil { + store.Close() + return nil, fmt.Errorf("attach telegram: %w", err) + } } // Worker — polling-планировщик dev-агента @@ -291,15 +293,15 @@ func (a *App) Run(ctx context.Context) error { } defer a.pool.Close() - // Канал для проверки Telegram-ошибки (горутина оборачивает Run) + // Telegram: long-poll цикл в горутине (только если канал включён) tgErr := make(chan error, 1) - - // Telegram: long-poll цикл в горутине - go func() { - tg := a.telegramChannel() - log.Print("app: telegram poll started") - tgErr <- tg.Run(ctx) - }() + if a.telegramChannel() != nil { + go func() { + tg := a.telegramChannel() + log.Print("app: telegram poll started") + tgErr <- tg.Run(ctx) + }() + } // Worker: poll-цикл (неблокирующий — стартует свою горутину) a.Worker.Start(ctx) @@ -320,6 +322,23 @@ func (a *App) Run(ctx context.Context) error { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + if a.telegramChannel() != nil { + select { + case <-ctx.Done(): + log.Print("app: context cancelled") + return nil + case sig := <-sigCh: + log.Printf("app: signal %s — shutting down", sig) + cancel() + return nil + case err := <-tgErr: + if err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("%w: %v", ErrChannelFatal, err) + } + return nil + } + } + select { case <-ctx.Done(): log.Print("app: context cancelled") @@ -328,11 +347,6 @@ func (a *App) Run(ctx context.Context) error { log.Printf("app: signal %s — shutting down", sig) cancel() return nil - case err := <-tgErr: - if err != nil && !errors.Is(err, context.Canceled) { - return fmt.Errorf("%w: %v", ErrChannelFatal, err) - } - return nil } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d9bcba7..de237f8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -277,7 +277,7 @@ paths: } func TestLoad_MissingRequired(t *testing.T) { - // пустой конфиг — все обязательные поля отсутствуют + // пустой конфиг — все обязательные поля отсутствуют (telegram.enabled по дефолту true) t.Setenv("TG_TOKEN", "") t.Setenv("TG_CHAT_ID", "") @@ -298,6 +298,41 @@ func TestLoad_MissingRequired(t *testing.T) { } } +func TestLoad_TelegramDisabledSkipsToken(t *testing.T) { + // enabled: false — token/chat_id не обязательны, конфиг валиден + t.Setenv("TG_TOKEN", "") + t.Setenv("TG_CHAT_ID", "") + + yaml := `telegram: + enabled: false + token: "" + chat_id: "" +` + path := writeCfg(t, yaml) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Telegram.Enabled { + t.Error("telegram.enabled = true, want false") + } +} + +func TestLoad_TelegramEnabledDefaultTrue(t *testing.T) { + // enabled не задан — дефолт true, token обязателен + t.Setenv("TG_TOKEN", "") + t.Setenv("TG_CHAT_ID", "42") + + yaml := `telegram: + chat_id: "42" +` + path := writeCfg(t, yaml) + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "telegram.token") { + t.Errorf("ожидалась ошибка telegram.token (enabled по дефолту true), got %v", err) + } +} + func TestLoad_InvalidDuration(t *testing.T) { t.Setenv("TG_TOKEN", "x") t.Setenv("TG_CHAT_ID", "x") diff --git a/internal/config/load.go b/internal/config/load.go index 198b5b8..71a4d78 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -35,6 +35,10 @@ func Load(path string) (*Config, error) { } } cfg := &Config{} + // Telegram по умолчанию включён (если в YAML не указан enabled). + // Делаем до unmarshal: yaml.Unmarshal перезапишет поле, только если ключ + // есть в документе, поэтому отсутствующий enabled останется true. + cfg.Telegram.Enabled = true if !noFile { expanded := os.Expand(string(raw), envLookup) if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { diff --git a/internal/config/types.go b/internal/config/types.go index f4918ca..fd22c2d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -76,9 +76,13 @@ type UpdateCfg struct { CheckInterval Duration `yaml:"check_interval" default:"24h"` } +// TelegramCfg — настройки Telegram-канала. Enabled=false отключает канал +// полностью: бот не опрашивается, сетевые вызовы к api.telegram.org не идут, +// token/chat_id не обязательны. По умолчанию включён (см. Load: presetTelegramEnabled). type TelegramCfg struct { - Token string `yaml:"token" env:"TG_TOKEN"` - ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"` + Enabled bool `yaml:"enabled"` + Token string `yaml:"token" env:"TG_TOKEN"` + ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"` } type OpenCodeCfg struct { @@ -121,11 +125,13 @@ type PathsCfg struct { // (склеенную, все ошибки сразу) или C2 InvalidFormat. func (c *Config) Validate() error { var errs []error - if c.Telegram.Token == "" { - errs = append(errs, fmt.Errorf("%w: telegram.token", ErrMissingField)) - } - if c.Telegram.ChatID == "" { - errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField)) + if c.Telegram.Enabled { + if c.Telegram.Token == "" { + errs = append(errs, fmt.Errorf("%w: telegram.token", ErrMissingField)) + } + if c.Telegram.ChatID == "" { + errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField)) + } } if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") { errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))