feat: настраиваемые каналы уведомлений (telegram/pachca/email) с маршрутизацией по пути

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vladimir V Maksimov
2026-06-14 11:10:44 +03:00
parent cfc9e23697
commit a21bb73175
6 changed files with 413 additions and 96 deletions

32
handler.go Normal file
View File

@@ -0,0 +1,32 @@
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"))
}
}

104
main.go
View File

@@ -1,117 +1,30 @@
package main
import (
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"time"
"gitea.mediatoday.ru/mt/notify"
)
func GetRemoteAddr(r *http.Request) string {
// Сначала смотрим X-Forwarded-For
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For может содержать несколько IP через запятую
ips := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Потом X-Real-IP
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
// И в конце RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func BuildMessage(r *http.Request) string {
t := time.Now()
var body []byte
if r.ContentLength > 1024 {
body = []byte("too long")
} else {
// Читаем тело запроса
body, _ = io.ReadAll(r.Body)
_ = r.Body.Close()
}
// Формируем содержимое
entry := fmt.Sprintf("[%s] %s %s%s\n", t.Format(time.RFC3339), r.Method, r.Host, r.URL.String())
entry += "RemoteAddr: " + GetRemoteAddr(r) + "\n"
entry += "Headers:\n"
for name, values := range r.Header {
for _, v := range values {
entry += fmt.Sprintf(" %s: %s\n", name, v)
}
}
entry += fmt.Sprintf("Body:\n%s\n", string(body))
entry += "----\n"
entry = "```" + entry + "```"
return entry
}
func main() {
log.Println("Запуск приложения")
// Загружаем конфиг
cfg, err := loadConfig("config.yaml")
if err != nil {
log.Fatalf("Ошибка загрузки конфига: %v", err)
}
if cfg.Telegram == nil {
log.Fatalf("не указана секция telegram")
}
tg, err := notify.NewTelegram(cfg.Telegram.Token, true)
notifiers, err := BuildNotifiers(cfg)
if err != nil {
log.Fatal(err)
log.Fatalf("Ошибка инициализации каналов: %v", err)
}
if len(notifiers) == 0 {
log.Fatal("не сконфигурирован ни один канал уведомлений")
}
// Общий обработчик
handler := func(w http.ResponseWriter, r *http.Request) {
paths := strings.Split(r.URL.Path, "/")
switch {
case len(paths) < 2:
w.WriteHeader(http.StatusNotFound)
return
case paths[1] == "telegram":
default:
w.WriteHeader(http.StatusNotFound)
return
}
http.HandleFunc("/", makeHandler(notifiers))
entry := BuildMessage(r)
_, err = tg.SendTextMessage(cfg.Telegram.GroupID, entry)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(err.Error()))
} else {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
}
// Регистрируем хендлер
http.HandleFunc("/", handler)
// Запускаем серверы для всех адресов
for _, addr := range cfg.ListenAddresses {
addr := addr // захватываем в область видимости
addr := addr
go func() {
log.Printf("Слушаю %s ...", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
@@ -120,6 +33,5 @@ func main() {
}()
}
// Блокируем main, чтобы программа не завершилась
select {}
}

165
notifier.go Normal file
View File

@@ -0,0 +1,165 @@
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...)
}
func splitHostPortLenient(addr string) (host, port string, err error) {
return net.SplitHostPort(addr)
}
// 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
}

93
notifier_test.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
var errSend = errors.New("send failed")
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)
}
}
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})
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)
}
rec = httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", "/unknown", strings.NewReader("x")))
if rec.Code != 404 {
t.Fatalf("unknown: code=%d", rec.Code)
}
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)
}
}

57
request.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"io"
"net"
"net/http"
"strings"
"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 := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
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
}
// 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.RequestURI(),
RemoteAddr: GetRemoteAddr(r),
Header: r.Header,
Body: string(body),
}
}

58
request_test.go Normal file
View File

@@ -0,0 +1,58 @@
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))
}
}