feat(config): отключаемый Telegram-канал (telegram.enabled)
- 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 обновлены
This commit is contained in:
@@ -40,6 +40,7 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do
|
|||||||
- **gitops (worker):** worktree-режим; feature-ветка `feat/<taskTag>` от origin/main; push через http.extraHeader, токен Bearer.
|
- **gitops (worker):** worktree-режим; feature-ветка `feat/<taskTag>` от origin/main; push через http.extraHeader, токен Bearer.
|
||||||
- **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd.
|
- **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd.
|
||||||
- **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-<sha7>/` (не `latest/`); Verify сверяет контрольную сумму бинаря против `.sha256` той же версии. **Perf:** `ResolveLatest` проверяет наличие бинаря версии **HEAD-пробой без скачивания тела** (405/501 → fallback `GET Range: bytes=0-0`), сортировка версий по id убыв.; один общий `http.Client` (keep-alive). Ошибка U4 — только при несовпадении суммы (пустой/отсутствующий `.sha256` пропускает проверку — M1-известное замечание).
|
- **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-<sha7>/` (не `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)
|
## opencode (v2 HTTP API, >= 1.18.18)
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ make run # build + ./ratatoskr -config config.yaml
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
telegram:
|
telegram:
|
||||||
token: "..." # TG_TOKEN
|
enabled: true # false — отключить Telegram (без сетевых вызовов к api.telegram.org)
|
||||||
chat_id: "..." # TG_CHAT_ID
|
token: "..." # TG_TOKEN (обязательно, если enabled)
|
||||||
|
chat_id: "..." # TG_CHAT_ID (обязательно, если enabled)
|
||||||
opencode:
|
opencode:
|
||||||
bin: "opencode"
|
bin: "opencode"
|
||||||
config_dir: "./agents" # каталог, куда распаковываются встроенные агенты
|
config_dir: "./agents" # каталог, куда распаковываются встроенные агенты
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# ratatoskr-go пример конфигурации.
|
# ratatoskr-go пример конфигурации.
|
||||||
# Можно не указывать поля, у которых есть дефолт (см. internal/config/types.go).
|
# Можно не указывать поля, у которых есть дефолт (см. internal/config/types.go).
|
||||||
# Обязательно: telegram.token + telegram.chat_id
|
# Если telegram.enabled: true (дефолт) — обязательно: telegram.token + telegram.chat_id.
|
||||||
|
|
||||||
telegram:
|
telegram:
|
||||||
token: "${TG_TOKEN}" # токен Telegram бота (обязательно)
|
enabled: true # false — полностью отключить Telegram (без сетевых вызовов)
|
||||||
chat_id: "${TG_CHAT_ID}" # ID чата (обязательно)
|
token: "${TG_TOKEN}" # токен Telegram бота (обязательно, если enabled)
|
||||||
|
chat_id: "${TG_CHAT_ID}" # ID чата (обязательно, если enabled)
|
||||||
|
|
||||||
# Всё ниже — опционально, показаны дефолты:
|
# Всё ниже — опционально, показаны дефолты:
|
||||||
# opencode:
|
# opencode:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type App struct {
|
|||||||
CoreCtx *core.Core
|
CoreCtx *core.Core
|
||||||
Worker *worker.Worker
|
Worker *worker.Worker
|
||||||
Updater *update.Updater
|
Updater *update.Updater
|
||||||
tg *telegram.Channel // сохранена для Run
|
tg *telegram.Channel // сохранена для Run (nil, если telegram.enabled=false)
|
||||||
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
||||||
|
|
||||||
// Events — доменная шина UI; LogEvents — шина логов (панель «Логи»).
|
// Events — доменная шина UI; LogEvents — шина логов (панель «Логи»).
|
||||||
@@ -224,12 +224,14 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-канал
|
// Telegram-канал (если включён; иначе сетевые вызовы к Telegram не идут)
|
||||||
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
|
if cfg.Telegram.Enabled {
|
||||||
a.tg = tg
|
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
|
||||||
if err := router.Attach(tg); err != nil {
|
a.tg = tg
|
||||||
store.Close()
|
if err := router.Attach(tg); err != nil {
|
||||||
return nil, fmt.Errorf("attach telegram: %w", err)
|
store.Close()
|
||||||
|
return nil, fmt.Errorf("attach telegram: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker — polling-планировщик dev-агента
|
// Worker — polling-планировщик dev-агента
|
||||||
@@ -291,15 +293,15 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
defer a.pool.Close()
|
defer a.pool.Close()
|
||||||
|
|
||||||
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
// Telegram: long-poll цикл в горутине (только если канал включён)
|
||||||
tgErr := make(chan error, 1)
|
tgErr := make(chan error, 1)
|
||||||
|
if a.telegramChannel() != nil {
|
||||||
// Telegram: long-poll цикл в горутине
|
go func() {
|
||||||
go func() {
|
tg := a.telegramChannel()
|
||||||
tg := a.telegramChannel()
|
log.Print("app: telegram poll started")
|
||||||
log.Print("app: telegram poll started")
|
tgErr <- tg.Run(ctx)
|
||||||
tgErr <- tg.Run(ctx)
|
}()
|
||||||
}()
|
}
|
||||||
|
|
||||||
// Worker: poll-цикл (неблокирующий — стартует свою горутину)
|
// Worker: poll-цикл (неблокирующий — стартует свою горутину)
|
||||||
a.Worker.Start(ctx)
|
a.Worker.Start(ctx)
|
||||||
@@ -320,6 +322,23 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Print("app: context cancelled")
|
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)
|
log.Printf("app: signal %s — shutting down", sig)
|
||||||
cancel()
|
cancel()
|
||||||
return nil
|
return nil
|
||||||
case err := <-tgErr:
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
return fmt.Errorf("%w: %v", ErrChannelFatal, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ paths:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_MissingRequired(t *testing.T) {
|
func TestLoad_MissingRequired(t *testing.T) {
|
||||||
// пустой конфиг — все обязательные поля отсутствуют
|
// пустой конфиг — все обязательные поля отсутствуют (telegram.enabled по дефолту true)
|
||||||
t.Setenv("TG_TOKEN", "")
|
t.Setenv("TG_TOKEN", "")
|
||||||
t.Setenv("TG_CHAT_ID", "")
|
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) {
|
func TestLoad_InvalidDuration(t *testing.T) {
|
||||||
t.Setenv("TG_TOKEN", "x")
|
t.Setenv("TG_TOKEN", "x")
|
||||||
t.Setenv("TG_CHAT_ID", "x")
|
t.Setenv("TG_CHAT_ID", "x")
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
|
// Telegram по умолчанию включён (если в YAML не указан enabled).
|
||||||
|
// Делаем до unmarshal: yaml.Unmarshal перезапишет поле, только если ключ
|
||||||
|
// есть в документе, поэтому отсутствующий enabled останется true.
|
||||||
|
cfg.Telegram.Enabled = true
|
||||||
if !noFile {
|
if !noFile {
|
||||||
expanded := os.Expand(string(raw), envLookup)
|
expanded := os.Expand(string(raw), envLookup)
|
||||||
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
||||||
|
|||||||
@@ -76,9 +76,13 @@ type UpdateCfg struct {
|
|||||||
CheckInterval Duration `yaml:"check_interval" default:"24h"`
|
CheckInterval Duration `yaml:"check_interval" default:"24h"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TelegramCfg — настройки Telegram-канала. Enabled=false отключает канал
|
||||||
|
// полностью: бот не опрашивается, сетевые вызовы к api.telegram.org не идут,
|
||||||
|
// token/chat_id не обязательны. По умолчанию включён (см. Load: presetTelegramEnabled).
|
||||||
type TelegramCfg struct {
|
type TelegramCfg struct {
|
||||||
Token string `yaml:"token" env:"TG_TOKEN"`
|
Enabled bool `yaml:"enabled"`
|
||||||
ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"`
|
Token string `yaml:"token" env:"TG_TOKEN"`
|
||||||
|
ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenCodeCfg struct {
|
type OpenCodeCfg struct {
|
||||||
@@ -121,11 +125,13 @@ type PathsCfg struct {
|
|||||||
// (склеенную, все ошибки сразу) или C2 InvalidFormat.
|
// (склеенную, все ошибки сразу) или C2 InvalidFormat.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
if c.Telegram.Token == "" {
|
if c.Telegram.Enabled {
|
||||||
errs = append(errs, fmt.Errorf("%w: telegram.token", ErrMissingField))
|
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.ChatID == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") {
|
if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") {
|
||||||
errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))
|
errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))
|
||||||
|
|||||||
Reference in New Issue
Block a user