docs: план реализации настраиваемых каналов уведомлений

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir V Maksimov
2026-06-14 10:57:16 +03:00
parent f63522f86e
commit 8a431ff02f

View File

@@ -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 14), а правку 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, "<pre>") || !strings.Contains(out, "</pre>") {
t.Fatalf("expected <pre> wrapping, got: %q", out)
}
// тело экранируется
if !strings.Contains(out, "payload&lt;&gt;") {
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 — обёртка в <pre> с экранированием для Email.
func formatHTML(d RequestData) string {
return "<pre>" + html.EscapeString(formatPlain(d)) + "</pre>"
}
// --- 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` может быть временно сломана, это отражено в примечаниях.