feat: add custom webhook payload templates and fix Telegram chat_id handling

- Add custom JSON payload template support for generic webhooks
- Users can now define custom webhook formats with Go template syntax
- Fix Telegram webhook issue where chat_id in URL caused 400 errors
- Automatically strip chat_id from URL and place in JSON body for Telegram
- Add comprehensive webhook documentation with examples
- Update API documentation with webhook endpoints

Addresses #305
This commit is contained in:
Pulse Monitor 2025-08-12 14:03:13 +00:00
parent 6765db9b13
commit b77df2f2f1
10 changed files with 542 additions and 81 deletions

View file

@ -243,6 +243,17 @@ docker logs pulse
journalctl -u pulse -f
```
## Documentation
- [Configuration Guide](docs/CONFIGURATION.md) - Complete setup and configuration
- [API Reference](docs/API.md) - REST API endpoints and examples
- [Webhook Guide](docs/WEBHOOKS.md) - Setting up webhooks and custom payloads
- [Reverse Proxy Setup](docs/REVERSE_PROXY.md) - nginx, Caddy, Apache, Traefik configs
- [PBS Agent](docs/PBS-AGENT.md) - Monitoring isolated PBS servers
- [Security](docs/SECURITY.md) - Security features and best practices
- [FAQ](docs/FAQ.md) - Common questions and troubleshooting
- [Migration Guide](docs/MIGRATION_V3_TO_V4.md) - Upgrading from v3 to v4
## Security
- Credentials stored encrypted (AES-256-GCM)

View file

@ -1,42 +0,0 @@
# Pulse v4.3.0 Release Notes
## ⚠️ BREAKING CHANGES
### Frontend Now Embedded in Binary
Starting with v4.3.0, the frontend is embedded directly in the Pulse binary. This is a significant architectural change that simplifies deployment but has some implications:
**What Changed:**
- Frontend files are now compiled into the Go binary at build time
- No separate `frontend-modern/` directory needed
- Single binary deployment - truly portable
- Smaller release tarballs
**Migration Notes:**
- **For manual installations**: Simply replace your binary. The old `frontend-modern/` directory can be deleted
- **For Docker users**: No changes needed, containers will work as normal
- **For developers**: Frontend changes now require recompiling the Go binary (use `scripts/rebuild-frontend.sh`)
- **Auto-updates**: Will work seamlessly from v4.2.1 to v4.3.0+
**Benefits:**
- Eliminates redirect loop issues (#304)
- No more path resolution problems
- Simpler installation and deployment
- Consistent behavior across all installation methods
- Reduced deployment size
## Features
- Embedded frontend for simplified single-binary deployment
- Improved installation experience
## Bug Fixes
- Fixed redirect loops in various installation scenarios (#304)
- Fixed path resolution issues with community scripts vs manual installs
## Technical Details
The frontend is embedded using Go's `embed` package. During the build process:
1. Frontend is built with npm
2. Built files are copied to `internal/api/frontend-modern/`
3. Go compiler embeds these files into the binary
4. Runtime serves embedded files directly from memory
This change makes Pulse a true single-binary application, eliminating an entire class of deployment issues.

View file

@ -177,6 +177,59 @@ POST /api/notifications/email/test # Send test email
GET /api/notifications/email-providers # List email providers
```
### Webhook Configuration
Manage webhook notification endpoints.
```bash
GET /api/notifications/webhooks # List all webhooks
POST /api/notifications/webhooks # Create new webhook
PUT /api/notifications/webhooks/<id> # Update webhook
DELETE /api/notifications/webhooks/<id> # Delete webhook
POST /api/notifications/webhooks/test # Test webhook
GET /api/notifications/webhook-templates # Get service templates
```
#### Create Webhook Example
```bash
curl -X POST http://localhost:7655/api/notifications/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Token: your-token" \
-d '{
"name": "Discord Alert",
"url": "https://discord.com/api/webhooks/xxx/yyy",
"method": "POST",
"service": "discord",
"enabled": true
}'
```
#### Custom Payload Template Example
```bash
curl -X POST http://localhost:7655/api/notifications/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Token: your-token" \
-d '{
"name": "Custom Webhook",
"url": "https://my-service.com/webhook",
"method": "POST",
"service": "generic",
"enabled": true,
"template": "{\"alert\": \"{{.Level}}: {{.Message}}\", \"value\": {{.Value}}}"
}'
```
#### Test Webhook
```bash
curl -X POST http://localhost:7655/api/notifications/webhooks/test \
-H "Content-Type: application/json" \
-H "X-API-Token: your-token" \
-d '{
"name": "Test",
"url": "https://example.com/webhook",
"service": "generic"
}'
```
### Alert Thresholds
Manage alert thresholds and overrides.

262
docs/WEBHOOKS.md Normal file
View file

@ -0,0 +1,262 @@
# Webhook Configuration Guide
Pulse supports sending alert notifications to various webhook services including Discord, Slack, Microsoft Teams, Telegram, PagerDuty, and any custom webhook endpoint.
## Quick Start
1. Navigate to **Settings****Alerts** → **Webhooks**
2. Click **Add Webhook**
3. Select your service type or choose "Generic" for custom webhooks
4. Enter the webhook URL and configure settings
5. Test the webhook to ensure it's working
6. Save your configuration
## Supported Services
### Discord
```
URL Format: https://discord.com/api/webhooks/{webhook_id}/{webhook_token}
```
1. In Discord, go to Server Settings → Integrations → Webhooks
2. Create a new webhook and copy the URL
3. Paste the URL in Pulse
### Telegram
```
URL Format: https://api.telegram.org/bot{bot_token}/sendMessage?chat_id={chat_id}
```
1. Create a bot with @BotFather on Telegram
2. Get your bot token
3. Get your chat ID by messaging the bot and visiting: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
4. Use the URL format above (Pulse will handle chat_id correctly)
### Slack
```
URL Format: https://hooks.slack.com/services/{webhook_path}
```
1. In Slack, go to Apps → Incoming Webhooks
2. Add to Slack and choose a channel
3. Copy the webhook URL
### Microsoft Teams
```
URL Format: https://{tenant}.webhook.office.com/webhookb2/{webhook_path}
```
1. In Teams channel, click ... → Connectors
2. Configure Incoming Webhook
3. Copy the URL
### PagerDuty
```
URL: https://events.pagerduty.com/v2/enqueue
```
1. In PagerDuty, go to Configuration → Services
2. Add an integration → Events API V2
3. Copy the Integration Key
4. Add the key as a header: `routing_key: YOUR_KEY`
## Custom Payload Templates
For generic webhooks, you can define custom JSON payloads using Go template syntax.
### Available Variables
| Variable | Description | Example Value |
|----------|-------------|---------------|
| `{{.ID}}` | Alert ID | "alert-123" |
| `{{.Level}}` | Alert level | "warning", "critical" |
| `{{.Type}}` | Resource type | "cpu", "memory", "disk" |
| `{{.ResourceName}}` | Name of the resource | "Web Server VM" |
| `{{.ResourceID}}` | Resource identifier | "vm-100" |
| `{{.Node}}` | Proxmox node name | "pve-node-01" |
| `{{.Instance}}` | Proxmox instance URL | "https://192.168.1.100:8006" |
| `{{.Message}}` | Alert message | "CPU usage exceeded 90%" |
| `{{.Value}}` | Current metric value | 95.5 |
| `{{.Threshold}}` | Alert threshold | 90.0 |
| `{{.Duration}}` | How long alert has been active | "5m" |
| `{{.Timestamp}}` | Current timestamp | "2024-01-15T10:30:00Z" |
| `{{.StartTime}}` | When alert started | "2024-01-15T10:25:00Z" |
### Template Functions
| Function | Description | Example |
|----------|-------------|---------|
| `{{.Level \| title}}` | Capitalize first letter | "Warning" |
| `{{.Level \| upper}}` | Uppercase | "WARNING" |
| `{{.Level \| lower}}` | Lowercase | "warning" |
| `{{printf "%.1f" .Value}}` | Format numbers | "95.5" |
### Example Templates
#### Simple JSON
```json
{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}
```
#### Formatted Alert
```json
{
"alert": {
"level": "{{.Level | upper}}",
"message": "{{.Message}}",
"details": {
"resource": "{{.ResourceName}}",
"node": "{{.Node}}",
"current_value": "{{printf "%.1f" .Value}}%",
"threshold": "{{printf "%.0f" .Threshold}}%",
"duration": "{{.Duration}}"
}
},
"timestamp": "{{.Timestamp}}"
}
```
#### Slack-Compatible Custom Format
```json
{
"text": "Pulse Alert",
"attachments": [{
"color": "{{if eq .Level "critical"}}danger{{else}}warning{{end}}",
"title": "{{.Level | title}} Alert: {{.ResourceName}}",
"text": "{{.Message}}",
"fields": [
{"title": "Value", "value": "{{printf "%.1f" .Value}}%", "short": true},
{"title": "Threshold", "value": "{{printf "%.0f" .Threshold}}%", "short": true},
{"title": "Node", "value": "{{.Node}}", "short": true},
{"title": "Duration", "value": "{{.Duration}}", "short": true}
],
"footer": "Pulse Monitoring",
"ts": {{.Timestamp}}
}]
}
```
#### Home Assistant
```json
{
"title": "Pulse Alert: {{.Level | title}}",
"message": "{{.Message}}",
"data": {
"entity_id": "sensor.{{.Node | lower}}_{{.Type}}",
"state": {{.Value}},
"attributes": {
"resource": "{{.ResourceName}}",
"threshold": {{.Threshold}},
"duration": "{{.Duration}}"
}
}
}
```
#### n8n / Node-RED
```json
{
"workflow": "pulse_alert",
"data": {
"alert_id": "{{.ID}}",
"level": "{{.Level}}",
"resource": "{{.ResourceName}}",
"node": "{{.Node}}",
"metric": {
"type": "{{.Type}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
},
"message": "{{.Message}}",
"timestamp": "{{.Timestamp}}"
}
}
```
## Testing Webhooks
1. After configuring a webhook, click the **Test** button
2. Pulse will send a test alert to verify the webhook is working
3. Check the receiving service to confirm the message arrived
4. If the test fails, verify:
- The URL is correct and accessible
- Any required authentication tokens are included
- The payload format matches what the service expects
## Troubleshooting
### Webhook Returns 400 Bad Request
- Check if the payload format is correct for your service
- For Telegram, ensure chat_id is in the URL (Pulse handles it automatically)
- Verify all required fields are present in custom templates
### Webhook Returns 401/403
- Check authentication tokens/keys
- Verify the webhook URL hasn't expired
- Ensure IP restrictions allow Pulse server
### No Notifications Received
- Verify the webhook is enabled
- Check alert thresholds are configured correctly
- Ensure notification cooldown period has passed
- Test the webhook manually using the Test button
## API Reference
### Create Webhook
```bash
POST /api/notifications/webhooks
Content-Type: application/json
{
"name": "My Webhook",
"url": "https://example.com/webhook",
"method": "POST",
"service": "generic",
"enabled": true,
"template": "{\"alert\": \"{{.Message}}\"}"
}
```
### Test Webhook
```bash
POST /api/notifications/webhooks/test
Content-Type: application/json
{
"name": "Test",
"url": "https://example.com/webhook",
"service": "generic",
"template": "{\"test\": true}"
}
```
### Update Webhook
```bash
PUT /api/notifications/webhooks/{id}
Content-Type: application/json
{
"name": "Updated Webhook",
"url": "https://example.com/new-webhook",
"enabled": false
}
```
### Delete Webhook
```bash
DELETE /api/notifications/webhooks/{id}
```
### List Webhooks
```bash
GET /api/notifications/webhooks
```
## Security Considerations
- **Never expose webhook URLs publicly** - they often contain authentication tokens
- **Use HTTPS URLs** when possible to encrypt data in transit
- **Rotate webhook URLs periodically** if they contain embedded tokens
- **Test webhooks carefully** to avoid sending test data to production channels
- **Limit webhook permissions** in the receiving service where possible

View file

@ -23,13 +23,14 @@ interface WebhookConfigProps {
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string }>({
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
@ -60,7 +61,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: data.method,
headers: data.headers,
enabled: data.enabled,
service: data.service
service: data.service,
template: data.payloadTemplate
};
props.onAdd(newWebhook);
// Reset form but keep adding state true
@ -70,7 +72,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
}
};
@ -84,7 +87,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
};
@ -92,7 +96,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
setEditingId(webhook.id!);
setFormData({
...webhook,
service: webhook.service || 'generic'
service: webhook.service || 'generic',
payloadTemplate: webhook.template || ''
});
setAdding(true);
};
@ -284,6 +289,33 @@ export function WebhookConfig(props: WebhookConfigProps) {
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Payload Template (JSON)
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
Optional - Leave empty to use default
</span>
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) => setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })}
placeholder={`{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}`}
rows={8}
class="w-full px-3 py-2 text-xs font-mono border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Available variables: {"{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}"}
</p>
</div>
</Show>
<div>
<label class="flex items-center gap-2">
<input

View file

@ -23,13 +23,14 @@ interface WebhookConfigProps {
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string }>({
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
@ -60,7 +61,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: data.method,
headers: data.headers,
enabled: data.enabled,
service: data.service
service: data.service,
template: data.payloadTemplate
};
props.onAdd(newWebhook);
// Reset form but keep adding state true
@ -70,7 +72,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
}
};
@ -84,7 +87,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
enabled: true,
payloadTemplate: ''
});
};
@ -92,7 +96,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
setEditingId(webhook.id!);
setFormData({
...webhook,
service: webhook.service || 'generic'
service: webhook.service || 'generic',
payloadTemplate: webhook.template || ''
});
setAdding(true);
};
@ -284,6 +289,33 @@ export function WebhookConfig(props: WebhookConfigProps) {
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Payload Template (JSON)
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
Optional - Leave empty to use default
</span>
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) => setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })}
placeholder={`{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}`}
rows={8}
class="w-full px-3 py-2 text-xs font-mono border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Available variables: {"{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}"}
</p>
</div>
</Show>
<div>
<label class="flex items-center gap-2">
<input

View file

@ -337,6 +337,11 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
RetryEnabled: false, // Don't retry during testing
}
// If the webhook has a custom template, use it
if basicWebhook.Template != "" {
webhook.PayloadTemplate = basicWebhook.Template
}
// Try to extract service from body if present
var serviceCheck struct {
Service string `json:"service"`
@ -354,23 +359,25 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
Str("name", webhook.Name).
Msg("Testing webhook")
// Get template for the service
templates := notifications.GetWebhookTemplates()
for _, tmpl := range templates {
if tmpl.Service == webhook.Service {
webhook.PayloadTemplate = tmpl.PayloadTemplate
if webhook.Headers == nil {
webhook.Headers = make(map[string]string)
// Get template for the service (if not using custom template)
if webhook.PayloadTemplate == "" {
templates := notifications.GetWebhookTemplates()
for _, tmpl := range templates {
if tmpl.Service == webhook.Service {
webhook.PayloadTemplate = tmpl.PayloadTemplate
if webhook.Headers == nil {
webhook.Headers = make(map[string]string)
}
for k, v := range tmpl.Headers {
webhook.Headers[k] = v
}
log.Info().Str("service", webhook.Service).Msg("Found template for service")
break
}
for k, v := range tmpl.Headers {
webhook.Headers[k] = v
}
log.Info().Str("service", webhook.Service).Msg("Found template for service")
break
}
}
// If no template found, use a simple generic template
// If still no template found, use a simple generic template
if webhook.PayloadTemplate == "" {
webhook.PayloadTemplate = `{
"alert": {

View file

@ -66,13 +66,14 @@ type EmailConfig struct {
// WebhookConfig holds webhook settings
type WebhookConfig struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Enabled bool `json:"enabled"`
Service string `json:"service"` // discord, slack, teams, etc.
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Enabled bool `json:"enabled"`
Service string `json:"service"` // discord, slack, teams, etc.
Template string `json:"template"` // Custom payload template
}
// NewNotificationManager creates a new notification manager
@ -404,8 +405,43 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis
var jsonData []byte
var err error
// For service-specific webhooks, use the first alert with a note about others
if webhook.Service != "" && webhook.Service != "generic" && len(alertList) > 0 {
// Check if webhook has a custom template first
if webhook.Template != "" && len(alertList) > 0 {
// Use custom template with the first alert (for simplicity)
alert := alertList[0]
if len(alertList) > 1 {
alert.Message = fmt.Sprintf("%s (and %d more alerts)", alert.Message, len(alertList)-1)
}
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
PayloadTemplate: webhook.Template,
}
data := n.prepareWebhookData(alert, nil)
// For Telegram, extract chat_id from URL if present
if webhook.Service == "telegram" && strings.Contains(webhook.URL, "chat_id=") {
if u, err := url.Parse(webhook.URL); err == nil {
chatID := u.Query().Get("chat_id")
if chatID != "" {
data.ChatID = chatID
}
}
}
jsonData, err = n.generatePayloadFromTemplate(enhanced.PayloadTemplate, data)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Int("alertCount", len(alertList)).
Msg("Failed to generate grouped payload from custom template")
return
}
} else if webhook.Service != "" && webhook.Service != "generic" && len(alertList) > 0 {
// For service-specific webhooks, use the first alert with a note about others
// For simplicity, send the first alert with a note about others
// Most webhook services work better with single structured payloads
alert := alertList[0]
@ -502,7 +538,23 @@ func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData
method = "POST"
}
req, err := http.NewRequest(method, webhook.URL, bytes.NewBuffer(jsonData))
// For Telegram webhooks, strip chat_id from URL if present
// The chat_id should only be in the JSON body, not the URL
webhookURL := webhook.URL
if webhook.Service == "telegram" && strings.Contains(webhookURL, "chat_id=") {
if u, err := url.Parse(webhookURL); err == nil {
q := u.Query()
q.Del("chat_id") // Remove chat_id from query params
u.RawQuery = q.Encode()
webhookURL = u.String()
log.Debug().
Str("original", webhook.URL).
Str("cleaned", webhookURL).
Msg("Stripped chat_id from Telegram webhook URL")
}
}
req, err := http.NewRequest(method, webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
log.Error().
Err(err).
@ -555,8 +607,39 @@ func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.A
var jsonData []byte
var err error
// Check if this webhook has a service type and use the proper template
if webhook.Service != "" && webhook.Service != "generic" {
// Check if webhook has a custom template first
if webhook.Template != "" {
// Use custom template provided by user
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
PayloadTemplate: webhook.Template,
}
// Prepare data and generate payload
data := n.prepareWebhookData(alert, nil)
// For Telegram, still extract chat_id from URL if present
if webhook.Service == "telegram" && strings.Contains(webhook.URL, "chat_id=") {
if u, err := url.Parse(webhook.URL); err == nil {
chatID := u.Query().Get("chat_id")
if chatID != "" {
data.ChatID = chatID
}
}
}
jsonData, err = n.generatePayloadFromTemplate(enhanced.PayloadTemplate, data)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("alertID", alert.ID).
Msg("Failed to generate webhook payload from custom template")
return
}
} else if webhook.Service != "" && webhook.Service != "generic" {
// Check if this webhook has a service type and use the proper template
// Convert to enhanced webhook to use template
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,

View file

@ -4,6 +4,8 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
@ -345,6 +347,16 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig)
// Prepare data
data := n.prepareWebhookData(testAlert, webhook.CustomFields)
// For Telegram, extract chat_id from URL if present
if webhook.Service == "telegram" && strings.Contains(webhook.URL, "chat_id=") {
if u, err := url.Parse(webhook.URL); err == nil {
chatID := u.Query().Get("chat_id")
if chatID != "" {
data.ChatID = chatID
}
}
}
// Generate payload
payload, err := n.generatePayloadFromTemplate(webhook.PayloadTemplate, data)
if err != nil {
@ -357,7 +369,18 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig)
method = "POST"
}
req, err := http.NewRequest(method, webhook.URL, bytes.NewBuffer(payload))
// For Telegram webhooks, strip chat_id from URL if present
webhookURL := webhook.URL
if webhook.Service == "telegram" && strings.Contains(webhookURL, "chat_id=") {
if u, err := url.Parse(webhookURL); err == nil {
q := u.Query()
q.Del("chat_id") // Remove chat_id from query params
u.RawQuery = q.Encode()
webhookURL = u.String()
}
}
req, err := http.NewRequest(method, webhookURL, bytes.NewBuffer(payload))
if err != nil {
return 0, "", fmt.Errorf("failed to create request: %w", err)
}

View file

@ -57,7 +57,7 @@ func GetWebhookTemplates() []WebhookTemplate {
"parse_mode": "Markdown",
"disable_web_page_preview": true
}`,
Instructions: "1. Create a bot with @BotFather on Telegram\n2. Get your bot token\n3. Get your chat ID by messaging the bot and visiting: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates\n4. URL format: https://api.telegram.org/bot<BOT_TOKEN>/sendMessage",
Instructions: "1. Create a bot with @BotFather on Telegram\n2. Get your bot token\n3. Get your chat ID by messaging the bot and visiting: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates\n4. URL format: https://api.telegram.org/bot<BOT_TOKEN>/sendMessage\n5. IMPORTANT: Do NOT include chat_id in the URL. Pulse will handle it automatically.",
},
{
Service: "slack",