diff --git a/docs/superpowers/plans/2026-06-14-notification-channels.md b/docs/superpowers/plans/2026-06-14-notification-channels.md new file mode 100644 index 0000000..ba52f61 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-notification-channels.md @@ -0,0 +1,849 @@ +# Настраиваемые каналы уведомлений — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать каналы уведомлений настраиваемыми через конфиг и добавить Pachca и Email к существующему Telegram, выбирая канал по пути URL (`/telegram`, `/pachca`, `/email`). + +**Architecture:** Общая функция `ExtractRequest` читает входящий HTTP-запрос один раз в нейтральную `RequestData`. Интерфейс `Notifier` с тремя реализациями (telegram/pachca/email) форматирует данные под свой канал и отправляет через библиотеку `gitea.mediatoday.ru/mt/notify`. На старте из конфига собирается `map[string]Notifier`; хендлер маршрутизирует по первому сегменту пути. + +**Tech Stack:** Go 1.25+, `gitea.mediatoday.ru/mt/notify`, `gopkg.in/yaml.v3`, стандартные `net/http`, `net/mail`, `net/smtp`, `html`, `net/http/httptest`. + +--- + +## Структура файлов + +- `go.mod` / `go.sum` — заменить зависимость `git.gm6.ru/icewind/notify` на `gitea.mediatoday.ru/mt/notify`. +- `config.go` — **создать**: типы конфига и `loadConfig` (вынести из `main.go`). +- `request.go` — **создать**: `RequestData`, `GetRemoteAddr`, `ExtractRequest`. +- `notifier.go` — **создать**: интерфейс `Notifier` и три реализации + `BuildNotifiers`. +- `main.go` — **изменить**: убрать вынесенное, собрать каналы, маршрутизировать по map. +- `request_test.go` — **создать**: тесты `ExtractRequest` / `GetRemoteAddr`. +- `notifier_test.go` — **создать**: тесты форматирования и маршрутизации хендлера через мок. +- `config.yaml.default` — **изменить**: добавить секции pachca/email, исправить опечатку. +- `handler.go` — **создать**: фабрика хендлера, принимающая `map[string]Notifier` (для тестируемости). + +Заметка по именованию: текущий тип называется `ConfigTelegraam` (опечатка) — переименовываем в `ConfigTelegram`. + +--- + +## Task 1: Обновить зависимость notify на новый путь + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` + +- [ ] **Step 1: Заменить импорт во всех .go файлах** + +В `main.go` строка импорта уже `git.gm6.ru/icewind/notify`. Заменить на новый путь: + +```go +"gitea.mediatoday.ru/mt/notify" +``` + +(Имя пакета остаётся `notify`, поэтому остальной код не меняется на этом шаге.) + +- [ ] **Step 2: Обновить модуль и зачистить старую зависимость** + +Run: +```bash +cd /home/soer/projects/http_logger +go get gitea.mediatoday.ru/mt/notify@v0.0.0-20260405185738-6f5db00fcb34 +go mod tidy +``` +Expected: в `go.mod` появляется `gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34` (без `// indirect`), строка `git.gm6.ru/icewind/notify` исчезает. + +- [ ] **Step 3: Проверить сборку** + +Run: `go build ./...` +Expected: успешная сборка без ошибок (код ещё прежний, только путь импорта новый). + +- [ ] **Step 4: Commit** + +```bash +git add go.mod go.sum main.go +git commit -m "build: перейти на gitea.mediatoday.ru/mt/notify" +``` + +--- + +## Task 2: Вынести конфиг в config.go с новыми секциями + +**Files:** +- Create: `config.go` +- Modify: `main.go` (удалить перенесённое) + +- [ ] **Step 1: Создать config.go** + +```go +package main + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +// Config — структура для чтения YAML-файла +type Config struct { + ListenAddresses []string `yaml:"listen_addresses"` + LogDir string `yaml:"log_dir"` + Telegram *ConfigTelegram `yaml:"telegram"` + Pachca *ConfigPachca `yaml:"pachca"` + Email *ConfigEmail `yaml:"email"` +} + +type ConfigTelegram struct { + Token string `yaml:"token"` + GroupID int64 `yaml:"group_id"` + DisableIPV6 *bool `yaml:"disable_ipv6"` // nil → true +} + +type ConfigPachca struct { + Token string `yaml:"token"` + ChatID int64 `yaml:"chat_id"` +} + +type ConfigEmail struct { + SMTPAddr string `yaml:"smtp_addr"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` + To []string `yaml:"to"` + Subject string `yaml:"subject"` // "" → "HTTP Logger" +} + +// loadConfig читает YAML-конфиг из файла +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} +``` + +- [ ] **Step 2: Удалить перенесённое из main.go** + +В `main.go` удалить: типы `Config`, `ConfigTelegraam`, функцию `loadConfig`, и импорты `os` и `gopkg.in/yaml.v3` (теперь не нужны в main.go). Оставить остальной код пока как есть — он будет переписан в Task 5. Импорт `yaml` и `os` останется неиспользуемым в main.go, поэтому временно сборка может ругаться — это нормально, исправится после удаления использования. Чтобы не ломать сборку между задачами, удаление импортов делается вместе с удалением типов в этом же шаге. + +- [ ] **Step 3: Проверить сборку** + +Run: `go build ./...` +Expected: успешная сборка (main.go всё ещё использует только `cfg.Telegram`, типы теперь из config.go). + +- [ ] **Step 4: Commit** + +```bash +git add config.go main.go +git commit -m "refactor: вынести конфиг в config.go, добавить секции pachca/email" +``` + +--- + +## Task 3: Извлечение данных запроса (request.go) — TDD + +**Files:** +- Create: `request.go` +- Create: `request_test.go` +- Modify: `main.go` (удалить `GetRemoteAddr` и часть `BuildMessage`) + +- [ ] **Step 1: Написать падающий тест** + +Создать `request_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetRemoteAddr(t *testing.T) { + tests := []struct { + name string + set func(r *http.Request) + want string + }{ + {"xff", func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2") }, "1.1.1.1"}, + {"realip", func(r *http.Request) { r.Header.Set("X-Real-IP", "3.3.3.3") }, "3.3.3.3"}, + {"remoteaddr", func(r *http.Request) { r.RemoteAddr = "4.4.4.4:5678" }, "4.4.4.4"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/telegram", nil) + r.RemoteAddr = "" + tt.set(r) + if got := GetRemoteAddr(r); got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) + } + }) + } +} + +func TestExtractRequest(t *testing.T) { + r := httptest.NewRequest("POST", "http://example.com/telegram?x=1", strings.NewReader("hello")) + r.Header.Set("X-Real-IP", "9.9.9.9") + data := ExtractRequest(r) + if data.Method != "POST" { + t.Errorf("method = %q", data.Method) + } + if data.RemoteAddr != "9.9.9.9" { + t.Errorf("remoteaddr = %q", data.RemoteAddr) + } + if data.Body != "hello" { + t.Errorf("body = %q", data.Body) + } + if data.URL != "/telegram?x=1" { + t.Errorf("url = %q", data.URL) + } +} + +func TestExtractRequestBodyLimit(t *testing.T) { + big := strings.Repeat("a", 2000) + r := httptest.NewRequest("POST", "/telegram", strings.NewReader(big)) + r.ContentLength = int64(len(big)) + data := ExtractRequest(r) + if data.Body != "too long" { + t.Errorf("expected 'too long', got len %d", len(data.Body)) + } +} +``` + +- [ ] **Step 2: Запустить тест — убедиться, что падает** + +Run: `go test ./... -run TestExtractRequest -v` +Expected: FAIL / build error — `GetRemoteAddr` и `ExtractRequest` ещё не существуют (после Task 5 они удалятся из main.go; на этом шаге их пока нет в request.go). + +- [ ] **Step 3: Создать request.go** + +```go +package main + +import ( + "io" + "net" + "net/http" + "time" +) + +// RequestData — извлечённые данные входящего запроса +type RequestData struct { + Time time.Time + Method string + Host string + URL string + RemoteAddr string + Header http.Header + Body string +} + +func GetRemoteAddr(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := splitFirst(xff) + if ips != "" { + return ips + } + } + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func splitFirst(xff string) string { + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return trimSpace(xff[:i]) + } + } + return trimSpace(xff) +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} + +// ExtractRequest читает запрос один раз и возвращает нейтральные данные. +func ExtractRequest(r *http.Request) RequestData { + var body []byte + if r.ContentLength > 1024 { + body = []byte("too long") + } else { + body, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + } + return RequestData{ + Time: time.Now(), + Method: r.Method, + Host: r.Host, + URL: r.URL.String(), + RemoteAddr: GetRemoteAddr(r), + Header: r.Header, + Body: string(body), + } +} +``` + +Заметка: `splitFirst`/`trimSpace` дублируют `strings`-логику без лишнего импорта; допустимо просто использовать `strings.Split`/`strings.TrimSpace` — если предпочитаешь `strings`, замени тело `GetRemoteAddr` на исходный вариант из старого `main.go` и добавь `"strings"` в импорт. Главное — поведение из теста. + +- [ ] **Step 4: Запустить тесты — убедиться, что проходят** + +Run: `go test ./... -run TestExtractRequest -v && go test ./... -run TestGetRemoteAddr -v` +Expected: PASS. (Если `GetRemoteAddr` всё ещё объявлен в main.go — будет ошибка дублирования; тогда сначала выполни Step 5.) + +- [ ] **Step 5: Удалить дубликаты из main.go** + +В `main.go` удалить функции `GetRemoteAddr` и `BuildMessage` целиком и неиспользуемые импорты (`io`, `net`, `time`, `fmt` — те, что больше не нужны). Логика `BuildMessage` (форматирование) переедет в notifier.go (Task 4). На этом шаге сборка main.go временно сломается, т.к. `handler` всё ещё вызывает `BuildMessage` — это будет починено в Task 5. Чтобы не коммитить несобираемое состояние, **объедини Step 5 с Task 5** при выполнении (см. примечание ниже). + +> Примечание для исполнителя: Task 3 и Task 5 затрагивают `main.go` совместно. Рекомендуется выполнять их подряд и коммитить после Task 5, либо закоммитить request.go + тесты отдельно (Step 1–4), а правку main.go сделать в Task 5. + +- [ ] **Step 6: Commit (только request.go и тесты)** + +```bash +git add request.go request_test.go +git commit -m "feat: ExtractRequest — извлечение данных запроса с тестами" +``` + +--- + +## Task 4: Интерфейс Notifier и реализации каналов — TDD + +**Files:** +- Create: `notifier.go` +- Create: `notifier_test.go` + +- [ ] **Step 1: Написать падающий тест форматирования** + +Создать `notifier_test.go`: + +```go +package main + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func sampleData() RequestData { + return RequestData{ + Time: time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC), + Method: "POST", + Host: "example.com", + URL: "/telegram", + RemoteAddr: "1.2.3.4", + Header: http.Header{"X-Test": []string{"v"}}, + Body: "payload<>", + } +} + +func TestFormatMarkdown(t *testing.T) { + out := formatMarkdown(sampleData()) + if !strings.HasPrefix(out, "```") || !strings.HasSuffix(out, "```") { + t.Fatalf("expected ``` wrapping, got: %q", out) + } + if !strings.Contains(out, "POST") || !strings.Contains(out, "1.2.3.4") { + t.Errorf("missing fields: %q", out) + } + if !strings.Contains(out, "X-Test: v") { + t.Errorf("missing header: %q", out) + } +} + +func TestFormatHTML(t *testing.T) { + out := formatHTML(sampleData()) + if !strings.Contains(out, "
") || !strings.Contains(out, "") { + t.Fatalf("expected
wrapping, got: %q", out)
+ }
+ // тело экранируется
+ if !strings.Contains(out, "payload<>") {
+ t.Errorf("body not escaped: %q", out)
+ }
+ if strings.Contains(out, "payload<>") {
+ t.Errorf("unescaped body present: %q", out)
+ }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться, что падает**
+
+Run: `go test ./... -run TestFormat -v`
+Expected: FAIL / build error — `formatMarkdown` и `formatHTML` не существуют.
+
+- [ ] **Step 3: Создать notifier.go**
+
+```go
+package main
+
+import (
+ "fmt"
+ "html"
+ "net/mail"
+ "net/smtp"
+ "strings"
+
+ "gitea.mediatoday.ru/mt/notify"
+)
+
+// Notifier — канал отправки уведомлений
+type Notifier interface {
+ Send(data RequestData) error
+}
+
+// formatPlain собирает текстовое тело без обёртки.
+func formatPlain(d RequestData) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "[%s] %s %s%s\n", d.Time.Format("2006-01-02T15:04:05Z07:00"), d.Method, d.Host, d.URL)
+ fmt.Fprintf(&b, "RemoteAddr: %s\n", d.RemoteAddr)
+ b.WriteString("Headers:\n")
+ for name, values := range d.Header {
+ for _, v := range values {
+ fmt.Fprintf(&b, " %s: %s\n", name, v)
+ }
+ }
+ fmt.Fprintf(&b, "Body:\n%s\n", d.Body)
+ b.WriteString("----\n")
+ return b.String()
+}
+
+// formatMarkdown — обёртка в код-блок для Telegram/Pachca.
+func formatMarkdown(d RequestData) string {
+ return "```" + formatPlain(d) + "```"
+}
+
+// formatHTML — обёртка в с экранированием для Email.
+func formatHTML(d RequestData) string {
+ return "" + html.EscapeString(formatPlain(d)) + "
"
+}
+
+// --- Telegram ---
+
+type telegramNotifier struct {
+ bot *notify.Bot
+ chatID int64
+}
+
+func (t *telegramNotifier) Send(d RequestData) error {
+ _, err := t.bot.SendTextMessage(t.chatID, formatMarkdown(d))
+ return err
+}
+
+// --- Pachca ---
+
+type pachcaNotifier struct {
+ client *notify.Pachca
+ chatID int64
+}
+
+func (p *pachcaNotifier) Send(d RequestData) error {
+ _, err := p.client.SendMessage(p.chatID, formatMarkdown(d))
+ return err
+}
+
+// --- Email ---
+
+type emailNotifier struct {
+ auth notify.SmtpAuth
+ from mail.Address
+ to []mail.Address
+ subject string
+}
+
+func (e *emailNotifier) Send(d RequestData) error {
+ return notify.SendEmailHTML(e.auth, formatHTML(d), e.subject, e.from, e.to...)
+}
+
+// BuildNotifiers собирает map каналов из конфига. Добавляет только заданные секции.
+func BuildNotifiers(cfg *Config) (map[string]Notifier, error) {
+ notifiers := map[string]Notifier{}
+
+ if cfg.Telegram != nil {
+ disableIPV6 := true
+ if cfg.Telegram.DisableIPV6 != nil {
+ disableIPV6 = *cfg.Telegram.DisableIPV6
+ }
+ bot, err := notify.NewTelegram(cfg.Telegram.Token, disableIPV6)
+ if err != nil {
+ return nil, fmt.Errorf("telegram: %w", err)
+ }
+ notifiers["telegram"] = &telegramNotifier{bot: bot, chatID: cfg.Telegram.GroupID}
+ }
+
+ if cfg.Pachca != nil {
+ notifiers["pachca"] = &pachcaNotifier{
+ client: notify.NewPachca(cfg.Pachca.Token),
+ chatID: cfg.Pachca.ChatID,
+ }
+ }
+
+ if cfg.Email != nil {
+ host, _, err := splitHostPortLenient(cfg.Email.SMTPAddr)
+ if err != nil {
+ return nil, fmt.Errorf("email smtp_addr: %w", err)
+ }
+ from, err := mail.ParseAddress(cfg.Email.From)
+ if err != nil {
+ return nil, fmt.Errorf("email from: %w", err)
+ }
+ var to []mail.Address
+ for _, addr := range cfg.Email.To {
+ a, err := mail.ParseAddress(addr)
+ if err != nil {
+ return nil, fmt.Errorf("email to %q: %w", addr, err)
+ }
+ to = append(to, *a)
+ }
+ if len(to) == 0 {
+ return nil, fmt.Errorf("email: не задан ни один получатель (to)")
+ }
+ subject := cfg.Email.Subject
+ if subject == "" {
+ subject = "HTTP Logger"
+ }
+ notifiers["email"] = &emailNotifier{
+ auth: notify.SmtpAuth{
+ Addr: cfg.Email.SMTPAddr,
+ Auth: smtp.PlainAuth("", cfg.Email.Username, cfg.Email.Password, host),
+ },
+ from: *from,
+ to: to,
+ subject: subject,
+ }
+ }
+
+ return notifiers, nil
+}
+```
+
+Добавить хелпер извлечения хоста (в notifier.go, под `BuildNotifiers`):
+
+```go
+import "net" // добавить в блок импортов notifier.go
+
+func splitHostPortLenient(addr string) (host, port string, err error) {
+ return net.SplitHostPort(addr)
+}
+```
+
+> Примечание: `splitHostPortLenient` — тонкая обёртка над `net.SplitHostPort`; можно вызвать `net.SplitHostPort` напрямую и убрать хелпер. Оставлено для явности. Не забудь добавить `"net"` в импорты notifier.go.
+
+- [ ] **Step 4: Запустить тесты — убедиться, что проходят**
+
+Run: `go test ./... -run TestFormat -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add notifier.go notifier_test.go
+git commit -m "feat: интерфейс Notifier + каналы telegram/pachca/email"
+```
+
+---
+
+## Task 5: Хендлер с маршрутизацией по map (handler.go) — TDD
+
+**Files:**
+- Create: `handler.go`
+- Create/Modify: `notifier_test.go` (добавить тесты хендлера)
+- Modify: `main.go`
+
+- [ ] **Step 1: Написать падающий тест хендлера**
+
+Добавить в `notifier_test.go`:
+
+```go
+import (
+ "net/http/httptest"
+)
+
+type mockNotifier struct {
+ called bool
+ err error
+}
+
+func (m *mockNotifier) Send(d RequestData) error {
+ m.called = true
+ return m.err
+}
+
+func TestHandlerRouting(t *testing.T) {
+ tg := &mockNotifier{}
+ h := makeHandler(map[string]Notifier{"telegram": tg})
+
+ // известный канал → 200, Send вызван
+ rec := httptest.NewRecorder()
+ h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
+ if rec.Code != 200 || !tg.called {
+ t.Fatalf("telegram: code=%d called=%v", rec.Code, tg.called)
+ }
+
+ // неизвестный канал → 404
+ rec = httptest.NewRecorder()
+ h(rec, httptest.NewRequest("POST", "/unknown", strings.NewReader("x")))
+ if rec.Code != 404 {
+ t.Fatalf("unknown: code=%d", rec.Code)
+ }
+
+ // пустой путь → 404
+ rec = httptest.NewRecorder()
+ h(rec, httptest.NewRequest("POST", "/", nil))
+ if rec.Code != 404 {
+ t.Fatalf("empty: code=%d", rec.Code)
+ }
+}
+
+func TestHandlerSendError(t *testing.T) {
+ failing := &mockNotifier{err: errSend}
+ h := makeHandler(map[string]Notifier{"telegram": failing})
+ rec := httptest.NewRecorder()
+ h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
+ if rec.Code != 502 {
+ t.Fatalf("expected 502, got %d", rec.Code)
+ }
+}
+```
+
+Добавить в начало `notifier_test.go` объявление ошибки рядом с другими переменными:
+
+```go
+import "errors"
+
+var errSend = errors.New("send failed")
+```
+
+- [ ] **Step 2: Запустить — убедиться, что падает**
+
+Run: `go test ./... -run TestHandler -v`
+Expected: FAIL / build error — `makeHandler` не существует.
+
+- [ ] **Step 3: Создать handler.go**
+
+```go
+package main
+
+import (
+ "log"
+ "net/http"
+ "strings"
+)
+
+// makeHandler возвращает http-хендлер, маршрутизирующий по первому сегменту пути.
+func makeHandler(notifiers map[string]Notifier) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ paths := strings.Split(r.URL.Path, "/")
+ if len(paths) < 2 || paths[1] == "" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ n, ok := notifiers[paths[1]]
+ if !ok {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ data := ExtractRequest(r)
+ if err := n.Send(data); err != nil {
+ log.Println(err)
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte(err.Error()))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("OK"))
+ }
+}
+```
+
+- [ ] **Step 4: Переписать main.go**
+
+`main.go` целиком:
+
+```go
+package main
+
+import (
+ "log"
+ "net/http"
+)
+
+func main() {
+ log.Println("Запуск приложения")
+
+ cfg, err := loadConfig("config.yaml")
+ if err != nil {
+ log.Fatalf("Ошибка загрузки конфига: %v", err)
+ }
+
+ notifiers, err := BuildNotifiers(cfg)
+ if err != nil {
+ log.Fatalf("Ошибка инициализации каналов: %v", err)
+ }
+ if len(notifiers) == 0 {
+ log.Fatal("не сконфигурирован ни один канал уведомлений")
+ }
+
+ http.HandleFunc("/", makeHandler(notifiers))
+
+ for _, addr := range cfg.ListenAddresses {
+ addr := addr
+ go func() {
+ log.Printf("Слушаю %s ...", addr)
+ if err := http.ListenAndServe(addr, nil); err != nil {
+ log.Printf("Ошибка на %s: %v", addr, err)
+ }
+ }()
+ }
+
+ select {}
+}
+```
+
+- [ ] **Step 5: Запустить все тесты**
+
+Run: `go test ./...`
+Expected: PASS по всем тестам (request, format, handler).
+
+- [ ] **Step 6: Проверить сборку бинаря**
+
+Run: `go build ./... && ./build.sh`
+Expected: успешная сборка, бинарь в `bin/http_logger`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add handler.go main.go notifier_test.go
+git commit -m "feat: маршрутизация каналов по пути URL через map[string]Notifier"
+```
+
+---
+
+## Task 6: Обновить config.yaml.default
+
+**Files:**
+- Modify: `config.yaml.default`
+
+- [ ] **Step 1: Переписать config.yaml.default**
+
+```yaml
+listen_addresses:
+ - ":8080"
+ - "127.0.0.1:9090"
+
+log_dir: "logs"
+
+# Любую секцию можно опустить — тогда соответствующий путь вернёт 404.
+
+telegram:
+ token: "*****"
+ group_id: -10012345
+ disable_ipv6: true # опционально, по умолчанию true
+
+pachca:
+ token: "*****"
+ chat_id: 12345
+
+email:
+ smtp_addr: "smtp.example.com:587"
+ username: "user@example.com"
+ password: "*****"
+ from: "logger@example.com"
+ to:
+ - "alert@example.com"
+ subject: "HTTP Logger" # опционально
+```
+
+- [ ] **Step 2: Проверить, что пример валиден**
+
+Run:
+```bash
+cp config.yaml.default /tmp/cfg_check.yaml
+go run . 2>&1 | head -3 || true
+```
+Примечание: запуск `go run .` попытается прочитать `config.yaml` (не `.default`). Достаточно убедиться, что YAML парсится — можно временно скопировать в `config.yaml`, запустить и сразу остановить (Ctrl+C), либо положиться на то, что структура совпадает с типами из config.go. Не коммитить реальный `config.yaml` (он в `.gitignore`).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add config.yaml.default
+git commit -m "docs: пример конфига с секциями pachca и email"
+```
+
+---
+
+## Task 7: Обновить README
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Записать README.md**
+
+```markdown
+# http_logger
+
+HTTP-логгер: принимает входящие HTTP-запросы и пересылает их в настроенный
+канал уведомлений. Канал выбирается первым сегментом пути:
+
+- `POST /telegram` → Telegram
+- `POST /pachca` → Pachca (Пачка)
+- `POST /email` → Email
+
+Каналы настраиваются в `config.yaml` (пример — `config.yaml.default`).
+Несконфигурированный путь возвращает `404`, ошибка отправки — `502`.
+
+## Сборка
+
+```bash
+./build.sh
+```
+
+## Запуск
+
+```bash
+cp config.yaml.default config.yaml # отредактировать токены
+./bin/http_logger
+```
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: описать настраиваемые каналы в README"
+```
+
+---
+
+## Self-Review (выполнено при написании плана)
+
+**Покрытие спеки:**
+- Путь = канал → Task 5 (makeHandler).
+- Одна секция на тип → Task 2 (Config).
+- Форматирование под канал → Task 4 (formatMarkdown/formatHTML).
+- Канал не сконфигурирован → 404 → Task 5 (тест TestHandlerRouting).
+- Обновление модуля notify → Task 1.
+- ExtractRequest читает тело один раз, лимит 1024 → Task 3.
+- Email: host из smtp_addr, PlainAuth, список получателей, subject default → Task 4 (BuildNotifiers).
+- Тесты: ExtractRequest/GetRemoteAddr (Task 3), форматирование (Task 4), маршрутизация через мок (Task 5).
+- Конфиг-пример + README → Task 6, 7.
+
+**Согласованность типов:** `Notifier.Send(RequestData) error`, `RequestData`, `BuildNotifiers`, `makeHandler`, `formatMarkdown`/`formatHTML`/`formatPlain` — имена совпадают во всех задачах.
+
+**Замечание по порядку:** Task 3 и Task 5 совместно правят `main.go`. Исполнителю рекомендовано коммитить request.go/тесты отдельно (Task 3), а правку main.go завершить в Task 5 — между этими задачами сборка `main.go` может быть временно сломана, это отражено в примечаниях.