first commit of restructured project
This commit is contained in:
86
trahn-trade-backend/internal/notifications/webhook.go
Normal file
86
trahn-trade-backend/internal/notifications/webhook.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/trahn-backend/internal/httputil"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
webhookURL string
|
||||
botName string
|
||||
httpClient *http.Client
|
||||
retry httputil.RetryConfig
|
||||
}
|
||||
|
||||
func NewSender(webhookURL, botName string) *Sender {
|
||||
if botName == "" {
|
||||
botName = "TrahnGridTrader"
|
||||
}
|
||||
return &Sender{
|
||||
webhookURL: webhookURL,
|
||||
botName: botName,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
retry: httputil.RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 1 * time.Second,
|
||||
MaxDelay: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(msg string) {
|
||||
formatted := fmt.Sprintf("[%s] %s", s.botName, msg)
|
||||
fmt.Printf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), formatted)
|
||||
|
||||
if s.webhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
payload := s.formatPayload(formatted)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("[CHAT ERROR] marshal: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := httputil.Do(ctx, s.httpClient, s.retry, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[CHAT ERROR] Failed to send notification after retries: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func (s *Sender) formatPayload(msg string) map[string]string {
|
||||
if strings.Contains(s.webhookURL, "discord") {
|
||||
return map[string]string{
|
||||
"content": msg,
|
||||
"username": s.botName,
|
||||
}
|
||||
}
|
||||
return map[string]string{
|
||||
"text": fmt.Sprintf("`%s`", msg),
|
||||
"username": s.botName,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Enabled() bool {
|
||||
return s.webhookURL != ""
|
||||
}
|
||||
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal file
83
trahn-trade-backend/internal/notifications/webhook_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSend_NoWebhook(t *testing.T) {
|
||||
s := NewSender("", "TestBot")
|
||||
if s.Enabled() {
|
||||
t.Fatal("should not be enabled with empty URL")
|
||||
}
|
||||
// Should log to console without error
|
||||
s.Send("hello from test")
|
||||
t.Log("Send with no webhook: OK (console only)")
|
||||
}
|
||||
|
||||
func TestSend_SlackFormat(t *testing.T) {
|
||||
var received map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := NewSender(srv.URL, "TestBot")
|
||||
if !s.Enabled() {
|
||||
t.Fatal("should be enabled")
|
||||
}
|
||||
|
||||
s.Send("grid recalculated")
|
||||
|
||||
if received["username"] != "TestBot" {
|
||||
t.Fatalf("username: got %s", received["username"])
|
||||
}
|
||||
if received["text"] == "" {
|
||||
t.Fatal("text should not be empty")
|
||||
}
|
||||
t.Logf("Slack payload: %+v", received)
|
||||
}
|
||||
|
||||
func TestSend_DiscordFormat(t *testing.T) {
|
||||
var received map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// URL containing "discord" triggers Discord format
|
||||
s := NewSender(srv.URL+"/discord/webhook", "TrahnBot")
|
||||
s.Send("trade executed: buy 0.04 ETH @ $2600")
|
||||
|
||||
if received["content"] == "" {
|
||||
t.Fatal("content should not be empty for Discord")
|
||||
}
|
||||
if received["username"] != "TrahnBot" {
|
||||
t.Fatalf("username: got %s", received["username"])
|
||||
}
|
||||
if _, hasText := received["text"]; hasText {
|
||||
t.Fatal("Discord payload should not have 'text' field")
|
||||
}
|
||||
t.Logf("Discord payload: %+v", received)
|
||||
}
|
||||
|
||||
func TestSend_WebhookError(t *testing.T) {
|
||||
s := NewSender("http://localhost:1/bogus", "TestBot")
|
||||
// Should not panic, just log the error
|
||||
s.Send("this will fail gracefully")
|
||||
t.Log("Webhook error handled gracefully")
|
||||
}
|
||||
|
||||
func TestDefaultBotName(t *testing.T) {
|
||||
s := NewSender("", "")
|
||||
if s.botName != "TrahnGridTrader" {
|
||||
t.Fatalf("expected default bot name, got %s", s.botName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user