From 8a431ff02f5c2d44c70bf4cb6ef9fb8a02aa3842 Mon Sep 17 00:00:00 2001 From: Vladimir V Maksimov Date: Sun, 14 Jun 2026 10:57:16 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-14-notification-channels.md | 849 ++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-notification-channels.md 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` может быть временно сломана, это отражено в примечаниях.