Files
http_logger/docs/superpowers/plans/2026-06-14-notification-channels.md
2026-06-14 11:05:14 +03:00

27 KiB
Raw Blame History

Настраиваемые каналы уведомлений — план реализации

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. Заменить на новый путь:

"gitea.mediatoday.ru/mt/notify"

(Имя пакета остаётся notify, поэтому остальной код не меняется на этом шаге.)

  • Step 2: Обновить модуль и зачистить старую зависимость

Run:

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
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

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 {
	WebhookURL string `yaml:"webhook_url"` // входящий вебхук Pachca
}

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
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:

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
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 и тесты)
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:

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
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html"
	"io"
	"net"
	"net/http"
	"net/mail"
	"net/smtp"
	"strings"
	"time"

	"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 {
	webhookURL string
	client     *http.Client
}

func (p *pachcaNotifier) Send(d RequestData) error {
	payload, err := json.Marshal(map[string]string{"message": formatMarkdown(d)})
	if err != nil {
		return err
	}
	resp, err := p.client.Post(p.webhookURL, "application/json", bytes.NewReader(payload))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("pachca webhook: HTTP %d: %s", resp.StatusCode, string(body))
	}
	return nil
}

// --- 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 {
		if cfg.Pachca.WebhookURL == "" {
			return nil, fmt.Errorf("pachca: не задан webhook_url")
		}
		notifiers["pachca"] = &pachcaNotifier{
			webhookURL: cfg.Pachca.WebhookURL,
			client:     &http.Client{Timeout: 30 * time.Second},
		}
	}

	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):

func splitHostPortLenient(addr string) (host, port string, err error) {
	return net.SplitHostPort(addr)
}

Примечание: splitHostPortLenient — тонкая обёртка над net.SplitHostPort; можно вызвать net.SplitHostPort напрямую и убрать хелпер. Оставлено для явности. "net" уже включён в блок импортов выше.

  • Step 4: Запустить тесты — убедиться, что проходят

Run: go test ./... -run TestFormat -v Expected: PASS.

  • Step 5: Commit
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:

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 объявление ошибки рядом с другими переменными:

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
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 целиком:

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
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

listen_addresses:
  - ":8080"
  - "127.0.0.1:9090"

log_dir: "logs"

# Любую секцию можно опустить — тогда соответствующий путь вернёт 404.

telegram:
  token: "*****"
  group_id: -10012345
  disable_ipv6: true        # опционально, по умолчанию true

pachca:
  webhook_url: "https://api.pachca.com/webhooks/XXXX"   # входящий вебхук

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:

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
git add config.yaml.default
git commit -m "docs: пример конфига с секциями pachca и email"

Task 7: Обновить README

Files:

  • Modify: README.md

  • Step 1: Записать README.md

# http_logger

HTTP-логгер: принимает входящие HTTP-запросы и пересылает их в настроенный
канал уведомлений. Канал выбирается первым сегментом пути:

- `POST /telegram` → Telegram
- `POST /pachca`   → Pachca (Пачка)
- `POST /email`    → Email

Каналы настраиваются в `config.yaml` (пример — `config.yaml.default`).
Несконфигурированный путь возвращает `404`, ошибка отправки — `502`.

## Сборка

```bash
./build.sh

Запуск

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 может быть временно сломана, это отражено в примечаниях.