From 5c1d2f104d9209d7c077d8468c5b2f9d3c9519bc Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Wed, 3 Jun 2026 15:01:48 +0300 Subject: [PATCH 1/3] feat: add multi-provider notification system with Telegram support --- internal/cli/notify/command.go | 64 ++++++++ internal/cli/notify/flags.go | 13 ++ internal/cli/notify/options.go | 82 ++++++++++ internal/cli/notify/telegram/command.go | 93 +++++++++++ internal/cli/notify/telegram/flags.go | 15 ++ internal/cli/notify/telegram/options.go | 65 ++++++++ internal/cli/root.go | 2 + internal/core/hook_core.go | 32 ++++ internal/domain/hook.go | 10 ++ internal/notify/provider.go | 53 ++++++ internal/notify/telegram.go | 86 ++++++++++ internal/repos/hook.go | 74 +++++++++ internal/server/contract.go | 5 + internal/server/handler.go | 153 ++++++++++++++++++ .../20260529000000_add_hook_notifications.sql | 14 ++ .../20260529000001_refactor_notifications.sql | 17 ++ internal/store/query/hooks.sql | 24 +++ internal/store/sqlc/hooks.sql.go | 110 +++++++++++++ internal/store/sqlc/models.go | 10 ++ .../endpoint-session/api/endpoint-api.ts | 23 +++ .../widgets/request-detail/request-detail.ts | 74 ++++++++- 21 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 internal/cli/notify/command.go create mode 100644 internal/cli/notify/flags.go create mode 100644 internal/cli/notify/options.go create mode 100644 internal/cli/notify/telegram/command.go create mode 100644 internal/cli/notify/telegram/flags.go create mode 100644 internal/cli/notify/telegram/options.go create mode 100644 internal/notify/provider.go create mode 100644 internal/notify/telegram.go create mode 100644 internal/store/migrations/20260529000000_add_hook_notifications.sql create mode 100644 internal/store/migrations/20260529000001_refactor_notifications.sql diff --git a/internal/cli/notify/command.go b/internal/cli/notify/command.go new file mode 100644 index 0000000..0dcc079 --- /dev/null +++ b/internal/cli/notify/command.go @@ -0,0 +1,64 @@ +package notify + +import ( + "context" + "net/url" + + "github.com/GaIsBAX/Webhix/internal/cli/notify/telegram" + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/spf13/cobra" +) + +type notificationChannel struct { + Provider string `json:"provider"` + Config map[string]string `json:"config"` +} + +func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { + opts := DefaultOptions() + if cfg.SecretKey != "" { + opts.AuthToken = cfg.SecretKey + } + + cmd := &cobra.Command{ + Use: "notify", + Short: "Manage endpoint notifications", + } + + RegisterFlags(cmd, &opts) + + cmd.AddCommand(newListCmd(ctx, &opts)) + cmd.AddCommand(telegram.NewCommand(ctx, &opts.Server, &opts.AuthToken)) + + return cmd +} + +func newListCmd(ctx context.Context, opts *Options) *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List all configured notification channels", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var channels []notificationChannel + if err := apiGet(ctx, opts, "/api/endpoints/"+url.PathEscape(args[0])+"/notifications", &channels); err != nil { + return err + } + + if len(channels) == 0 { + cmd.Println("No notifications configured.") + return nil + } + + for _, ch := range channels { + cmd.Printf("Provider: %s\n", ch.Provider) + for k, v := range ch.Config { + if k == "bot_token" { + v = maskToken(v) + } + cmd.Printf(" %s: %s\n", k, v) + } + } + return nil + }, + } +} diff --git a/internal/cli/notify/flags.go b/internal/cli/notify/flags.go new file mode 100644 index 0000000..05f9f5e --- /dev/null +++ b/internal/cli/notify/flags.go @@ -0,0 +1,13 @@ +package notify + +import "github.com/spf13/cobra" + +const ( + flagServer = "server" + flagAuthToken = "auth-token" +) + +func RegisterFlags(cmd *cobra.Command, opt *Options) { + cmd.PersistentFlags().StringVar(&opt.Server, flagServer, opt.Server, "Webhix server URL") + cmd.PersistentFlags().StringVar(&opt.AuthToken, flagAuthToken, opt.AuthToken, "auth token (env: WEBHIX_SECRET_KEY)") +} diff --git a/internal/cli/notify/options.go b/internal/cli/notify/options.go new file mode 100644 index 0000000..8937699 --- /dev/null +++ b/internal/cli/notify/options.go @@ -0,0 +1,82 @@ +package notify + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +type Options struct { + Server string + AuthToken string +} + +type apiResponse struct { + Success bool `json:"success"` + Body json.RawMessage `json:"body"` + Error *apiError `json:"error"` +} + +type apiError struct { + Message string `json:"message"` +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func DefaultOptions() Options { + return Options{ + Server: "http://localhost:8080", + } +} + +func (o *Options) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, o.Server+path, body) + if err != nil { + return nil, err + } + if o.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+o.AuthToken) + } + return req, nil +} + +func apiGet(ctx context.Context, opts *Options, path string, out any) error { + req, err := opts.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + var ar apiResponse + if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { + return err + } + if !ar.Success { + if ar.Error != nil { + return errors.New(ar.Error.Message) + } + return fmt.Errorf("server returned %d", resp.StatusCode) + } + return json.Unmarshal(ar.Body, out) +} + +func maskToken(token string) string { + if len(token) <= 14 { + return "***" + } + return token[:4] + "..." + token[len(token)-4:] +} diff --git a/internal/cli/notify/telegram/command.go b/internal/cli/notify/telegram/command.go new file mode 100644 index 0000000..0839049 --- /dev/null +++ b/internal/cli/notify/telegram/command.go @@ -0,0 +1,93 @@ +package telegram + +import ( + "context" + "net/http" + "net/url" + + "github.com/spf13/cobra" +) + +func NewCommand(ctx context.Context, server, authToken *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "telegram", + Short: "Manage Telegram notifications", + } + + cmd.AddCommand(newSetCmd(ctx, server, authToken)) + cmd.AddCommand(newTestCmd(ctx, server, authToken)) + cmd.AddCommand(newRemoveCmd(ctx, server, authToken)) + + return cmd +} + +func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command { + opts := Options{} + + cmd := &cobra.Command{ + Use: "set ", + Short: "Configure Telegram notifications for an endpoint", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := map[string]string{"bot_token": opts.BotToken, "chat_id": opts.ChatID} + if opts.ProxyURL != "" { + cfg["proxy_url"] = opts.ProxyURL + } + + body := map[string]any{"provider": "telegram", "config": cfg} + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := do(ctx, http.MethodPut, path, *authToken, body); err != nil { + return err + } + + cmd.Println("Telegram notifications configured.") + return nil + }, + } + + RegisterFlags(cmd, &opts) + must(cmd.MarkFlagRequired(flagBotToken)) + must(cmd.MarkFlagRequired(flagChatID)) + + return cmd +} + +func newTestCmd(ctx context.Context, server, authToken *string) *cobra.Command { + return &cobra.Command{ + Use: "test ", + Short: "Send a test Telegram message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test" + if err := do(ctx, http.MethodPost, path, *authToken, nil); err != nil { + return err + } + + cmd.Println("Test message sent.") + return nil + }, + } +} + +func newRemoveCmd(ctx context.Context, server, authToken *string) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove Telegram notifications from an endpoint", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := do(ctx, http.MethodDelete, path, *authToken, nil); err != nil { + return err + } + + cmd.Println("Telegram notifications removed.") + return nil + }, + } +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/internal/cli/notify/telegram/flags.go b/internal/cli/notify/telegram/flags.go new file mode 100644 index 0000000..5d50721 --- /dev/null +++ b/internal/cli/notify/telegram/flags.go @@ -0,0 +1,15 @@ +package telegram + +import "github.com/spf13/cobra" + +const ( + flagBotToken = "bot-token" + flagChatID = "chat" + flagProxyURL = "proxy" +) + +func RegisterFlags(cmd *cobra.Command, opt *Options) { + cmd.Flags().StringVar(&opt.BotToken, flagBotToken, opt.BotToken, "Telegram bot token") + cmd.Flags().StringVar(&opt.ChatID, flagChatID, opt.ChatID, "Telegram chat ID") + cmd.Flags().StringVar(&opt.ProxyURL, flagProxyURL, opt.ProxyURL, "Proxy URL (e.g. socks5://127.0.0.1:1080)") +} diff --git a/internal/cli/notify/telegram/options.go b/internal/cli/notify/telegram/options.go new file mode 100644 index 0000000..87d292c --- /dev/null +++ b/internal/cli/notify/telegram/options.go @@ -0,0 +1,65 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +type Options struct { + BotToken string + ChatID string + ProxyURL string +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func do(ctx context.Context, method, url, authToken string, body any) error { + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + r = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, r) + if err != nil { + return err + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + var ar struct { + Error *struct{ Message string `json:"message"` } `json:"error"` + } + if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil { + return errors.New(ar.Error.Message) + } + return fmt.Errorf("server returned %d", resp.StatusCode) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6280d06..75dcba8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "github.com/GaIsBAX/Webhix/internal/cli/forward" + "github.com/GaIsBAX/Webhix/internal/cli/notify" "github.com/GaIsBAX/Webhix/internal/cli/serve" "github.com/GaIsBAX/Webhix/internal/cli/tunnel" "github.com/GaIsBAX/Webhix/internal/cli/version" @@ -29,6 +30,7 @@ func NewRootCommand( cmd.AddCommand(serve.NewCommand(ctx, cfg, serveFactory)) cmd.AddCommand(forward.NewCommand(ctx, cfg)) + cmd.AddCommand(notify.NewCommand(ctx, cfg)) cmd.AddCommand(tunnel.NewCommand(ctx, cfg)) cmd.AddCommand(version.NewCommand(ctx)) diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 0488a69..8d239db 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -19,6 +19,10 @@ type HookRepository interface { ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) + ListNotificationChannels(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) + GetNotificationChannel(ctx context.Context, hookID int64, provider string) (domain.NotificationChannel, error) + UpsertNotificationChannel(ctx context.Context, hookID int64, provider string, config map[string]string) (domain.NotificationChannel, error) + DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error } type Hook struct { @@ -108,6 +112,34 @@ func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain. return s.repo.UpsertHookResponse(ctx, hook.ID, params) } +func (s *Hook) ListChannels(ctx context.Context, token string) ([]domain.NotificationChannel, error) { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return nil, err + } + return s.repo.ListNotificationChannels(ctx, hook.ID) +} + +func (s *Hook) UpsertChannel(ctx context.Context, token, provider string, config map[string]string) (domain.NotificationChannel, error) { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return domain.NotificationChannel{}, err + } + return s.repo.UpsertNotificationChannel(ctx, hook.ID, provider, config) +} + +func (s *Hook) DeleteChannel(ctx context.Context, token, provider string) error { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return err + } + return s.repo.DeleteNotificationChannel(ctx, hook.ID, provider) +} + +func (s *Hook) GetChannelsForHookID(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) { + return s.repo.ListNotificationChannels(ctx, hookID) +} + func defaultHookResponse() domain.HookResponse { return domain.HookResponse{ StatusCode: defaultHookResponseStatusCode, diff --git a/internal/domain/hook.go b/internal/domain/hook.go index 98911e2..c90c672 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -61,3 +61,13 @@ func (p UpsertHookResponseParams) Validate() error { } return nil } + +type NotificationChannel struct { + ID int64 + HookID int64 + Provider string + Config map[string]string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/notify/provider.go b/internal/notify/provider.go new file mode 100644 index 0000000..bfc72a4 --- /dev/null +++ b/internal/notify/provider.go @@ -0,0 +1,53 @@ +package notify + +import ( + "context" + "fmt" + "sort" + "sync" +) + +type Config map[string]string + +type Provider interface { + Send(ctx context.Context, config Config, message string) error +} + +type ProviderFunc func(ctx context.Context, config Config, message string) error + +func (f ProviderFunc) Send(ctx context.Context, config Config, message string) error { + return f(ctx, config, message) +} + +var ( + registryMu sync.RWMutex + registry = map[string]Provider{ + "telegram": ProviderFunc(telegramSend), + } +) + +func Send(ctx context.Context, provider string, config Config, message string) error { + registryMu.RLock() + + p, ok := registry[provider] + registryMu.RUnlock() + + if !ok { + return fmt.Errorf("unknown provider: %s", provider) + } + + return p.Send(ctx, config, message) +} + +func KnownProviders() []string { + registryMu.RLock() + + keys := make([]string, 0, len(registry)) + for k := range registry { + keys = append(keys, k) + } + + registryMu.RUnlock() + sort.Strings(keys) + return keys +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..6167e27 --- /dev/null +++ b/internal/notify/telegram.go @@ -0,0 +1,86 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" +) + +// telegramSend implements Provider for Telegram via ProviderFunc. +func telegramSend(ctx context.Context, config Config, message string) error { + return sendMessage(ctx, config["bot_token"], config["chat_id"], message, config["proxy_url"]) +} + +func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) error { + if botToken == "" || chatID == "" { + return fmt.Errorf("telegram: bot_token and chat_id are required") + } + + client := &http.Client{Timeout: 10 * time.Second} + if proxyURL != "" { + proxy, err := validateProxy(proxyURL) + if err != nil { + return err + } + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxy)} + } + + // text already contains HTML from the caller (handler.go escapes user data before calling Send) + payload, err := json.Marshal(map[string]string{ + "chat_id": chatID, + "text": text, + "parse_mode": "HTML", + }) + if err != nil { + return err + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + if resp.StatusCode == http.StatusOK { + return nil + } + + var tgErr struct { + Description string `json:"description"` + } + if err := json.NewDecoder(resp.Body).Decode(&tgErr); err == nil && tgErr.Description != "" { + return fmt.Errorf("telegram: %s", tgErr.Description) + } + return fmt.Errorf("telegram API returned %d", resp.StatusCode) +} + +// validateProxy parses and validates the proxy URL scheme. +// Returns the parsed URL so the caller doesn't parse it twice. +func validateProxy(rawURL string) (*url.URL, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL: %w", err) + } + switch u.Scheme { + case "http", "https", "socks5": + return u, nil + default: + return nil, fmt.Errorf("proxy scheme %q not allowed: use http, https, or socks5", u.Scheme) + } +} diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 99d39d0..3713339 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "log/slog" "github.com/GaIsBAX/Webhix/internal/domain" "github.com/GaIsBAX/Webhix/internal/store/sqlc" @@ -123,6 +124,79 @@ func (r *Hook) UpsertHookResponse(ctx context.Context, hookID int64, params doma return toDomainHookResponse(row), nil } +func (r *Hook) ListNotificationChannels(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) { + rows, err := r.q.ListNotificationChannels(ctx, hookID) + if err != nil { + return nil, err + } + + result := make([]domain.NotificationChannel, len(rows)) + for i, row := range rows { + result[i] = toDomainChannel(row) + } + + return result, nil +} + +func (r *Hook) GetNotificationChannel(ctx context.Context, hookID int64, provider string) (domain.NotificationChannel, error) { + row, err := r.q.GetNotificationChannel(ctx, sqlc.GetNotificationChannelParams{ + HookID: hookID, + Provider: provider, + }) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.NotificationChannel{}, domain.ErrNotFound + } + return domain.NotificationChannel{}, err + } + + return toDomainChannel(row), nil +} + +func (r *Hook) UpsertNotificationChannel(ctx context.Context, hookID int64, provider string, config map[string]string) (domain.NotificationChannel, error) { + configJSON, err := json.Marshal(config) + if err != nil { + return domain.NotificationChannel{}, err + } + + row, err := r.q.UpsertNotificationChannel(ctx, sqlc.UpsertNotificationChannelParams{ + HookID: hookID, + Provider: provider, + Config: string(configJSON), + }) + + if err != nil { + return domain.NotificationChannel{}, err + } + + return toDomainChannel(row), nil +} + +func (r *Hook) DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error { + return r.q.DeleteNotificationChannel(ctx, sqlc.DeleteNotificationChannelParams{ + HookID: hookID, + Provider: provider, + }) +} + +func toDomainChannel(row sqlc.HookNotificationChannel) domain.NotificationChannel { + cfg := map[string]string{} + if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil { + slog.Warn("parse notification channel config", "err", err) + } + + return domain.NotificationChannel{ + ID: row.ID, + HookID: row.HookID, + Provider: row.Provider, + Config: cfg, + Enabled: row.Enabled != 0, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + func toDomainHookResponse(row sqlc.HookResponse) domain.HookResponse { headers := map[string]string{} if err := json.Unmarshal([]byte(row.Headers), &headers); err != nil { diff --git a/internal/server/contract.go b/internal/server/contract.go index 11f5f73..599b3b6 100644 --- a/internal/server/contract.go +++ b/internal/server/contract.go @@ -47,6 +47,11 @@ type HookResponseContract struct { Body string `json:"body"` } +type NotificationContract struct { + Provider string `json:"provider"` + Config map[string]string `json:"config"` +} + type SetHookResponseRequestContract struct { StatusCode int `json:"statusCode"` Headers map[string]string `json:"headers"` diff --git a/internal/server/handler.go b/internal/server/handler.go index 1364e48..621874f 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -5,11 +5,13 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log/slog" "net/http" "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/notify" ) const DefaultMaxBodySize int64 = 5 << 20 // 5MB @@ -21,6 +23,10 @@ type HookService interface { ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) + ListChannels(ctx context.Context, token string) ([]domain.NotificationChannel, error) + UpsertChannel(ctx context.Context, token, provider string, config map[string]string) (domain.NotificationChannel, error) + DeleteChannel(ctx context.Context, token, provider string) error + GetChannelsForHookID(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) } type EventBroker interface { @@ -61,6 +67,10 @@ func (h *Hook) RegisterRoutes() { h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/response", h.GetResponse) h.deps.Mux.HandleFunc("PUT /api/endpoints/{token}/response", h.SetResponse) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/notifications", h.GetNotification) + h.deps.Mux.HandleFunc("PUT /api/endpoints/{token}/notifications/{provider}", h.SetNotification) + h.deps.Mux.HandleFunc("DELETE /api/endpoints/{token}/notifications/{provider}", h.DeleteNotification) + h.deps.Mux.HandleFunc("POST /api/endpoints/{token}/notifications/{provider}/test", h.TestNotification) h.deps.Mux.HandleFunc("/r/{token}", h.ReceiveWebhook) } @@ -188,6 +198,7 @@ func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { } h.deps.Hub.Publish(token, data) + go h.sendNotifications(req, token, context.WithoutCancel(r.Context())) if customResp.StatusCode > 0 { for k, v := range customResp.Headers { @@ -340,6 +351,148 @@ func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } +func (h *Hook) GetNotification(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + channels, err := h.deps.Service.ListChannels(r.Context(), token) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("list notification channels", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + contracts := make([]NotificationContract, len(channels)) + for i, ch := range channels { + contracts[i] = NotificationContract{Provider: ch.Provider, Config: ch.Config} + } + + data, err := json.Marshal(contracts) + if err != nil { + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, data) +} + +func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + + token := r.PathValue("token") + provider := r.PathValue("provider") + + contract, err := DecodeRequest[NotificationContract](r) + if err != nil { + SendError(w, http.StatusBadRequest, ErrBadRequest) + return + } + + ch, err := h.deps.Service.UpsertChannel(r.Context(), token, provider, contract.Config) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("upsert notification channel", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + data, err := json.Marshal(NotificationContract{Provider: ch.Provider, Config: ch.Config}) + if err != nil { + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, data) +} + +func (h *Hook) DeleteNotification(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + + token := r.PathValue("token") + provider := r.PathValue("provider") + + if err := h.deps.Service.DeleteChannel(r.Context(), token, provider); err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("delete notification channel", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, []byte(`{}`)) +} + +func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + provider := r.PathValue("provider") + + channels, err := h.deps.Service.ListChannels(r.Context(), token) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("list channels for test", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + for _, ch := range channels { + if ch.Provider != provider { + continue + } + msg := fmt.Sprintf("✅ Webhix test notification for endpoint /r/%s", html.EscapeString(token)) + if err := notify.Send(r.Context(), ch.Provider, notify.Config(ch.Config), msg); err != nil { + slog.Error("test notification", "provider", provider, "err", err) + SendError(w, http.StatusBadGateway, WithDetails(ErrInternal, ErrorDetailContract{ + Field: provider, + Message: err.Error(), + })) + return + } + SendSuccess(w, http.StatusOK, []byte(`{"sent":true}`)) + return + } + + SendError(w, http.StatusNotFound, WithDetails(ErrNotFound, ErrorDetailContract{ + Field: "provider", + Message: provider + " is not configured for this endpoint", + })) +} + +func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx context.Context) { + channels, err := h.deps.Service.GetChannelsForHookID(ctx, req.HookID) + if err != nil || len(channels) == 0 { + return + } + + msg := fmt.Sprintf( + "📨 New webhook\nEndpoint: /r/%s\nMethod: %s\nPath: %s", + html.EscapeString(token), html.EscapeString(req.Method), html.EscapeString(req.Path), + ) + + for _, ch := range channels { + if !ch.Enabled { + continue + } + if err := notify.Send(ctx, ch.Provider, notify.Config(ch.Config), msg); err != nil { + slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) + } + } +} + func (h *Hook) readOnly(w http.ResponseWriter) bool { if !h.deps.Opts.ReadOnly { return false diff --git a/internal/store/migrations/20260529000000_add_hook_notifications.sql b/internal/store/migrations/20260529000000_add_hook_notifications.sql new file mode 100644 index 0000000..20f7252 --- /dev/null +++ b/internal/store/migrations/20260529000000_add_hook_notifications.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE hook_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL UNIQUE, + telegram_bot_token TEXT NOT NULL DEFAULT '', + telegram_chat_id TEXT NOT NULL DEFAULT '', + proxy_url TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE IF EXISTS hook_notifications; diff --git a/internal/store/migrations/20260529000001_refactor_notifications.sql b/internal/store/migrations/20260529000001_refactor_notifications.sql new file mode 100644 index 0000000..3c106fc --- /dev/null +++ b/internal/store/migrations/20260529000001_refactor_notifications.sql @@ -0,0 +1,17 @@ +-- +goose Up +DROP TABLE IF EXISTS hook_notifications; + +CREATE TABLE hook_notification_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL, + provider TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE, + UNIQUE(hook_id, provider) +); + +-- +goose Down +DROP TABLE IF EXISTS hook_notification_channels; diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index 49ea469..5c94f97 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -75,3 +75,27 @@ ORDER BY created_at DESC; SELECT id, hook_id, status_code, headers, body, created_at, updated_at FROM hook_responses WHERE hook_id = ?; + +-- name: ListNotificationChannels :many +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? +ORDER BY provider; + +-- name: GetNotificationChannel :one +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? AND provider = ?; + +-- name: UpsertNotificationChannel :one +INSERT INTO hook_notification_channels (hook_id, provider, config) +VALUES (?, ?, ?) +ON CONFLICT (hook_id, provider) DO UPDATE SET + config = excluded.config, + enabled = 1, + updated_at = CURRENT_TIMESTAMP +RETURNING id, hook_id, provider, config, enabled, created_at, updated_at; + +-- name: DeleteNotificationChannel :exec +DELETE FROM hook_notification_channels +WHERE hook_id = ? AND provider = ?; diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 8a792b6..5badfdf 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -92,6 +92,21 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq return i, err } +const deleteNotificationChannel = `-- name: DeleteNotificationChannel :exec +DELETE FROM hook_notification_channels +WHERE hook_id = ? AND provider = ? +` + +type DeleteNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` +} + +func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) error { + _, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.HookID, arg.Provider) + return err +} + const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult DELETE FROM webhook_requests WHERE received_at < datetime('now', ?) @@ -153,6 +168,32 @@ func (q *Queries) GetHookResponseByHookID(ctx context.Context, hookID int64) (Ho return i, err } +const getNotificationChannel = `-- name: GetNotificationChannel :one +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? AND provider = ? +` + +type GetNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` +} + +func (q *Queries) GetNotificationChannel(ctx context.Context, arg GetNotificationChannelParams) (HookNotificationChannel, error) { + row := q.db.QueryRowContext(ctx, getNotificationChannel, arg.HookID, arg.Provider) + var i HookNotificationChannel + err := row.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listHooks = `-- name: ListHooks :many SELECT id, token, name, created_at, updated_at FROM hooks @@ -187,6 +228,44 @@ func (q *Queries) ListHooks(ctx context.Context) ([]Hook, error) { return items, nil } +const listNotificationChannels = `-- name: ListNotificationChannels :many +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? +ORDER BY provider +` + +func (q *Queries) ListNotificationChannels(ctx context.Context, hookID int64) ([]HookNotificationChannel, error) { + rows, err := q.db.QueryContext(ctx, listNotificationChannels, hookID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HookNotificationChannel + for rows.Next() { + var i HookNotificationChannel + if err := rows.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listWebhookRequestsByHookID = `-- name: ListWebhookRequestsByHookID :many SELECT id, hook_id, method, path, query, headers, body, remote_addr, content_type, body_size, received_at FROM webhook_requests @@ -337,3 +416,34 @@ func (q *Queries) UpsertHookResponse(ctx context.Context, arg UpsertHookResponse ) return i, err } + +const upsertNotificationChannel = `-- name: UpsertNotificationChannel :one +INSERT INTO hook_notification_channels (hook_id, provider, config) +VALUES (?, ?, ?) +ON CONFLICT (hook_id, provider) DO UPDATE SET + config = excluded.config, + enabled = 1, + updated_at = CURRENT_TIMESTAMP +RETURNING id, hook_id, provider, config, enabled, created_at, updated_at +` + +type UpsertNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` + Config string `json:"config"` +} + +func (q *Queries) UpsertNotificationChannel(ctx context.Context, arg UpsertNotificationChannelParams) (HookNotificationChannel, error) { + row := q.db.QueryRowContext(ctx, upsertNotificationChannel, arg.HookID, arg.Provider, arg.Config) + var i HookNotificationChannel + err := row.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 0c8fe84..9d98d7c 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -17,6 +17,16 @@ type Hook struct { UpdatedAt time.Time `json:"updated_at"` } +type HookNotificationChannel struct { + ID int64 `json:"id"` + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` + Config string `json:"config"` + Enabled int64 `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type HookResponse struct { ID int64 `json:"id"` HookID int64 `json:"hook_id"` diff --git a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts index 0fd6ab2..c770117 100644 --- a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts +++ b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts @@ -57,6 +57,29 @@ export async function saveHookResponse(token: string, data: HookResponse): Promi if (!json.success) throw new Error(json.error?.message || 'Failed to save'); } +export interface Notification { + telegramBotToken: string; + telegramChatId: string; + proxyUrl: string; +} + +export async function fetchNotification(token: string): Promise { + const response = await fetch(`/api/endpoints/${token}/notifications`); + const json = (await response.json()) as ApiResponse; + if (!json.success || !json.body) return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; + return json.body; +} + +export async function saveNotification(token: string, data: Notification): Promise { + const response = await fetch(`/api/endpoints/${token}/notifications`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const json = (await response.json()) as ApiResponse; + if (!json.success) throw new Error(json.error?.message || 'Failed to save'); +} + export function connectEvents( token: string, handlers: { diff --git a/internal/web/ui/src/widgets/request-detail/request-detail.ts b/internal/web/ui/src/widgets/request-detail/request-detail.ts index 88bb1a2..5f94480 100644 --- a/internal/web/ui/src/widgets/request-detail/request-detail.ts +++ b/internal/web/ui/src/widgets/request-detail/request-detail.ts @@ -12,6 +12,8 @@ import { import { fetchHookResponse, saveHookResponse, + fetchNotification, + saveNotification, } from '../../features/endpoint-session/api/endpoint-api'; export function renderSelectedDetail(elements: Elements, state: AppState): void { @@ -237,7 +239,51 @@ function createSettingsForm(token: string | null): HTMLDivElement { saveBtn.className = 'settings-save-btn'; saveBtn.textContent = 'Save'; - wrap.append(statusLabel, statusInput, headersLabel, headersInput, bodyLabel, bodyInput, saveBtn); + // Telegram notifications section + const divider = document.createElement('hr'); + divider.className = 'settings-divider'; + + const tgTitle = document.createElement('h4'); + tgTitle.className = 'settings-section-title'; + tgTitle.textContent = 'Telegram Notifications'; + + const tgTokenLabel = document.createElement('label'); + tgTokenLabel.textContent = 'Bot Token'; + const tgTokenInput = document.createElement('input'); + tgTokenInput.type = 'text'; + tgTokenInput.className = 'settings-input'; + tgTokenInput.placeholder = '123456:ABC-DEF...'; + + const tgChatLabel = document.createElement('label'); + tgChatLabel.textContent = 'Chat ID'; + const tgChatInput = document.createElement('input'); + tgChatInput.type = 'text'; + tgChatInput.className = 'settings-input'; + tgChatInput.placeholder = '-1001234567890'; + + const tgProxyLabel = document.createElement('label'); + tgProxyLabel.textContent = 'Proxy URL (optional)'; + const tgProxyInput = document.createElement('input'); + tgProxyInput.type = 'text'; + tgProxyInput.className = 'settings-input'; + tgProxyInput.placeholder = 'socks5://127.0.0.1:1080'; + + const tgSaveBtn = document.createElement('button'); + tgSaveBtn.className = 'settings-save-btn'; + tgSaveBtn.textContent = 'Save Notifications'; + + wrap.append( + statusLabel, statusInput, + headersLabel, headersInput, + bodyLabel, bodyInput, + saveBtn, + divider, + tgTitle, + tgTokenLabel, tgTokenInput, + tgChatLabel, tgChatInput, + tgProxyLabel, tgProxyInput, + tgSaveBtn, + ); if (token) { void fetchHookResponse(token).then((resp) => { @@ -248,6 +294,12 @@ function createSettingsForm(token: string | null): HTMLDivElement { bodyInput.value = resp.body || ''; }); + void fetchNotification(token).then((n) => { + tgTokenInput.value = n.telegramBotToken; + tgChatInput.value = n.telegramChatId; + tgProxyInput.value = n.proxyUrl; + }); + saveBtn.addEventListener('click', () => { let headers: Record = {}; try { @@ -278,6 +330,26 @@ function createSettingsForm(token: string | null): HTMLDivElement { saveBtn.disabled = false; }); }); + + tgSaveBtn.addEventListener('click', () => { + tgSaveBtn.disabled = true; + void saveNotification(token, { + telegramBotToken: tgTokenInput.value.trim(), + telegramChatId: tgChatInput.value.trim(), + proxyUrl: tgProxyInput.value.trim(), + }) + .then(() => { + tgSaveBtn.textContent = 'Saved!'; + setTimeout(() => (tgSaveBtn.textContent = 'Save Notifications'), 2000); + }) + .catch(() => { + tgSaveBtn.textContent = 'Error'; + setTimeout(() => (tgSaveBtn.textContent = 'Save Notifications'), 2000); + }) + .finally(() => { + tgSaveBtn.disabled = false; + }); + }); } return wrap; From c01fba3cf228e8cf61a5ee9a1424c11255403365 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 7 Jun 2026 20:51:59 +0300 Subject: [PATCH 2/3] style: fix go fmt formatting in telegram options --- internal/cli/notify/telegram/options.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cli/notify/telegram/options.go b/internal/cli/notify/telegram/options.go index 87d292c..91011a7 100644 --- a/internal/cli/notify/telegram/options.go +++ b/internal/cli/notify/telegram/options.go @@ -56,7 +56,9 @@ func do(ctx context.Context, method, url, authToken string, body any) error { } var ar struct { - Error *struct{ Message string `json:"message"` } `json:"error"` + Error *struct { + Message string `json:"message"` + } `json:"error"` } if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil { return errors.New(ar.Error.Message) From cbf2194f7ed9ba4639574e174a9d7066954188cb Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 7 Jun 2026 20:57:35 +0300 Subject: [PATCH 3/3] style: fix prettier formatting in notification UI files --- .../endpoint-session/api/endpoint-api.ts | 3 ++- .../widgets/request-detail/request-detail.ts | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts index c770117..e0a4d90 100644 --- a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts +++ b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts @@ -66,7 +66,8 @@ export interface Notification { export async function fetchNotification(token: string): Promise { const response = await fetch(`/api/endpoints/${token}/notifications`); const json = (await response.json()) as ApiResponse; - if (!json.success || !json.body) return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; + if (!json.success || !json.body) + return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; return json.body; } diff --git a/internal/web/ui/src/widgets/request-detail/request-detail.ts b/internal/web/ui/src/widgets/request-detail/request-detail.ts index 5f94480..3004bcc 100644 --- a/internal/web/ui/src/widgets/request-detail/request-detail.ts +++ b/internal/web/ui/src/widgets/request-detail/request-detail.ts @@ -273,15 +273,21 @@ function createSettingsForm(token: string | null): HTMLDivElement { tgSaveBtn.textContent = 'Save Notifications'; wrap.append( - statusLabel, statusInput, - headersLabel, headersInput, - bodyLabel, bodyInput, + statusLabel, + statusInput, + headersLabel, + headersInput, + bodyLabel, + bodyInput, saveBtn, divider, tgTitle, - tgTokenLabel, tgTokenInput, - tgChatLabel, tgChatInput, - tgProxyLabel, tgProxyInput, + tgTokenLabel, + tgTokenInput, + tgChatLabel, + tgChatInput, + tgProxyLabel, + tgProxyInput, tgSaveBtn, );