Add webhook formats and Google Chat support
Introduce selectable webhook payload formats and Google Chat rich-card support. NotificationGroupController now reads and validates a webhook_format option (generic, google_chat, simple_text) and logs a warning if Google Chat format is chosen but the URL does not look like chat.googleapis.com. WebhookChannel gains format constants, a payload builder (generic/simple text/Google Chat card), improved Content-Type header, enhanced logging with masked URLs, response truncation, payload previews, and better RequestException handling. Views updated to expose a Webhook Format dropdown, contextual help (including Google Chat setup instructions), dynamic placeholders/help text, and include the selected format when testing/saving webhooks. These changes add format flexibility and improve observability and safety when sending webhook notifications.
This commit is contained in:
@@ -591,7 +591,26 @@ class NotificationGroupController extends Controller
|
|||||||
if (!str_starts_with($webhookUrl, 'https://') && !str_starts_with($webhookUrl, 'http://')) {
|
if (!str_starts_with($webhookUrl, 'https://') && !str_starts_with($webhookUrl, 'http://')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['webhook_url' => $webhookUrl];
|
|
||||||
|
$config = ['webhook_url' => $webhookUrl];
|
||||||
|
|
||||||
|
// Add format option (generic, google_chat, simple_text)
|
||||||
|
$format = trim($data['webhook_format'] ?? 'generic');
|
||||||
|
$validFormats = ['generic', 'google_chat', 'simple_text'];
|
||||||
|
if (in_array($format, $validFormats)) {
|
||||||
|
$config['format'] = $format;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Google Chat webhook URL format if that format is selected
|
||||||
|
if ($format === 'google_chat' && !str_contains($webhookUrl, 'chat.googleapis.com')) {
|
||||||
|
// Allow it but log a warning - user might have a proxy
|
||||||
|
$logger = new \App\Services\Logger();
|
||||||
|
$logger->warning('Google Chat format selected but URL does not appear to be a Google Chat webhook', [
|
||||||
|
'url' => $webhookUrl
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class WebhookChannel implements NotificationChannelInterface
|
|||||||
private Client $httpClient;
|
private Client $httpClient;
|
||||||
private Logger $logger;
|
private Logger $logger;
|
||||||
|
|
||||||
|
// Supported webhook formats
|
||||||
|
public const FORMAT_GENERIC = 'generic';
|
||||||
|
public const FORMAT_GOOGLE_CHAT = 'google_chat';
|
||||||
|
public const FORMAT_SIMPLE_TEXT = 'simple_text';
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->httpClient = new Client(['timeout' => 10]);
|
$this->httpClient = new Client(['timeout' => 10]);
|
||||||
@@ -23,18 +28,13 @@ class WebhookChannel implements NotificationChannelInterface
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a sane, generic JSON payload for automation tools (n8n, Zapier, etc.)
|
$format = $config['format'] ?? self::FORMAT_GENERIC;
|
||||||
$payload = [
|
$payload = $this->buildPayload($format, $message, $data);
|
||||||
'event' => 'domain_expiration_alert',
|
|
||||||
'message' => $message,
|
|
||||||
'data' => $data,
|
|
||||||
'sent_at' => date('c')
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->httpClient->post($url, [
|
$response = $this->httpClient->post($url, [
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Content-Type' => 'application/json'
|
'Content-Type' => 'application/json; charset=UTF-8'
|
||||||
],
|
],
|
||||||
'json' => $payload
|
'json' => $payload
|
||||||
]);
|
]);
|
||||||
@@ -43,24 +43,223 @@ class WebhookChannel implements NotificationChannelInterface
|
|||||||
$ok = $status >= 200 && $status < 300;
|
$ok = $status >= 200 && $status < 300;
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$this->logger->info('Webhook sent successfully', [
|
$this->logger->info('Webhook sent successfully', [
|
||||||
'url' => $url,
|
'url' => $this->maskUrl($url),
|
||||||
|
'format' => $format,
|
||||||
'status' => $status
|
'status' => $status
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
$responseBody = (string) $response->getBody();
|
||||||
$this->logger->error('Webhook responded with non-2xx', [
|
$this->logger->error('Webhook responded with non-2xx', [
|
||||||
'url' => $url,
|
'url' => $this->maskUrl($url),
|
||||||
'status' => $status
|
'format' => $format,
|
||||||
|
'status' => $status,
|
||||||
|
'response_body' => $this->truncate($responseBody, 1000),
|
||||||
|
'payload_preview' => $this->getPayloadPreview($payload)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return $ok;
|
return $ok;
|
||||||
|
} catch (\GuzzleHttp\Exception\RequestException $e) {
|
||||||
|
// Guzzle request exception - may have response details
|
||||||
|
$errorDetails = [
|
||||||
|
'url' => $this->maskUrl($url),
|
||||||
|
'format' => $format,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'payload_preview' => $this->getPayloadPreview($payload)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include response body if available (e.g., 4xx/5xx errors)
|
||||||
|
if ($e->hasResponse()) {
|
||||||
|
$response = $e->getResponse();
|
||||||
|
$errorDetails['status'] = $response->getStatusCode();
|
||||||
|
$errorDetails['response_body'] = $this->truncate((string) $response->getBody(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('Webhook request failed', $errorDetails);
|
||||||
|
return false;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('Webhook send failed', [
|
$this->logger->error('Webhook send failed', [
|
||||||
'url' => $url,
|
'url' => $this->maskUrl($url),
|
||||||
'exception' => $e->getMessage()
|
'format' => $format,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'payload_preview' => $this->getPayloadPreview($payload)
|
||||||
]);
|
]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build payload based on format type
|
||||||
|
*/
|
||||||
|
private function buildPayload(string $format, string $message, array $data): array
|
||||||
|
{
|
||||||
|
return match ($format) {
|
||||||
|
self::FORMAT_GOOGLE_CHAT => $this->buildGoogleChatPayload($message, $data),
|
||||||
|
self::FORMAT_SIMPLE_TEXT => ['text' => $message],
|
||||||
|
default => $this->buildGenericPayload($message, $data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build generic payload for automation tools (n8n, Zapier, Make, etc.)
|
||||||
|
*/
|
||||||
|
private function buildGenericPayload(string $message, array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'domain_expiration_alert',
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
'sent_at' => date('c')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Google Chat payload with rich card formatting
|
||||||
|
* Uses the official Google Chat webhook format
|
||||||
|
*/
|
||||||
|
private function buildGoogleChatPayload(string $message, array $data): array
|
||||||
|
{
|
||||||
|
// If we have domain data, create a rich card message
|
||||||
|
if (isset($data['domain']) && isset($data['expiration_date'])) {
|
||||||
|
return $this->buildGoogleChatCard($message, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text message for test messages and other notifications
|
||||||
|
return ['text' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rich card payload for Google Chat domain expiration alerts
|
||||||
|
*/
|
||||||
|
private function buildGoogleChatCard(string $message, array $data): array
|
||||||
|
{
|
||||||
|
$domain = $data['domain'] ?? 'Unknown';
|
||||||
|
$daysLeft = $data['days_left'] ?? 'N/A';
|
||||||
|
$expirationDate = $data['expiration_date'] ?? 'Unknown';
|
||||||
|
$registrar = $data['registrar'] ?? 'Unknown';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cardsV2' => [
|
||||||
|
[
|
||||||
|
'cardId' => 'domain-alert-' . time(),
|
||||||
|
'card' => [
|
||||||
|
'header' => [
|
||||||
|
'title' => 'Domain Expiration Alert',
|
||||||
|
'subtitle' => $domain,
|
||||||
|
'imageUrl' => 'https://www.gstatic.com/images/branding/product/2x/hats_notification_96dp.png',
|
||||||
|
'imageType' => 'CIRCLE'
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'header' => 'Alert Details',
|
||||||
|
'collapsible' => false,
|
||||||
|
'widgets' => [
|
||||||
|
[
|
||||||
|
'decoratedText' => [
|
||||||
|
'topLabel' => 'Domain',
|
||||||
|
'text' => $domain,
|
||||||
|
'startIcon' => [
|
||||||
|
'knownIcon' => 'BOOKMARK'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'decoratedText' => [
|
||||||
|
'topLabel' => 'Days Remaining',
|
||||||
|
'text' => (string) $daysLeft,
|
||||||
|
'startIcon' => [
|
||||||
|
'knownIcon' => 'CLOCK'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'decoratedText' => [
|
||||||
|
'topLabel' => 'Expiration Date',
|
||||||
|
'text' => $expirationDate,
|
||||||
|
'startIcon' => [
|
||||||
|
'knownIcon' => 'EVENT_SEAT'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'decoratedText' => [
|
||||||
|
'topLabel' => 'Registrar',
|
||||||
|
'text' => $registrar,
|
||||||
|
'startIcon' => [
|
||||||
|
'knownIcon' => 'STORE'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'widgets' => [
|
||||||
|
[
|
||||||
|
'textParagraph' => [
|
||||||
|
'text' => $message
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive parts of URL for logging (hide keys/tokens)
|
||||||
|
*/
|
||||||
|
private function maskUrl(string $url): string
|
||||||
|
{
|
||||||
|
// Mask query parameters that might contain keys/tokens
|
||||||
|
return preg_replace('/([?&](key|token|secret|password|auth)=)[^&]+/i', '$1***MASKED***', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate string to max length for logging
|
||||||
|
*/
|
||||||
|
private function truncate(string $text, int $maxLength): string
|
||||||
|
{
|
||||||
|
if (strlen($text) <= $maxLength) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
return substr($text, 0, $maxLength) . '... [truncated]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a preview of the payload for debugging (without sensitive data)
|
||||||
|
*/
|
||||||
|
private function getPayloadPreview(array $payload): array
|
||||||
|
{
|
||||||
|
// For simple text payloads, show the text (truncated)
|
||||||
|
if (isset($payload['text'])) {
|
||||||
|
return [
|
||||||
|
'type' => 'text',
|
||||||
|
'text_preview' => $this->truncate($payload['text'], 200)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For card payloads (Google Chat), show structure info
|
||||||
|
if (isset($payload['cardsV2'])) {
|
||||||
|
return [
|
||||||
|
'type' => 'google_chat_card',
|
||||||
|
'card_count' => count($payload['cardsV2'])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For generic payloads, show event and truncated message
|
||||||
|
if (isset($payload['event'])) {
|
||||||
|
return [
|
||||||
|
'type' => 'generic',
|
||||||
|
'event' => $payload['event'],
|
||||||
|
'message_preview' => $this->truncate($payload['message'] ?? '', 200),
|
||||||
|
'data_keys' => isset($payload['data']) ? array_keys($payload['data']) : []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'unknown', 'keys' => array_keys($payload)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ ob_start();
|
|||||||
echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A');
|
echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A');
|
||||||
} elseif ($channel['channel_type'] === 'pushover') {
|
} elseif ($channel['channel_type'] === 'pushover') {
|
||||||
echo "User: " . substr(htmlspecialchars($config['user_key'] ?? 'N/A'), 0, 10) . "...";
|
echo "User: " . substr(htmlspecialchars($config['user_key'] ?? 'N/A'), 0, 10) . "...";
|
||||||
|
} elseif ($channel['channel_type'] === 'webhook') {
|
||||||
|
$formatLabels = [
|
||||||
|
'generic' => 'Generic',
|
||||||
|
'google_chat' => 'Google Chat',
|
||||||
|
'simple_text' => 'Simple Text'
|
||||||
|
];
|
||||||
|
$format = $config['format'] ?? 'generic';
|
||||||
|
echo "Format: " . ($formatLabels[$format] ?? ucfirst($format));
|
||||||
} else {
|
} else {
|
||||||
echo "Webhook configured";
|
echo "Webhook configured";
|
||||||
}
|
}
|
||||||
@@ -356,9 +364,25 @@ ob_start();
|
|||||||
|
|
||||||
<!-- Generic Webhook Fields -->
|
<!-- Generic Webhook Fields -->
|
||||||
<div id="webhook_fields" class="hidden space-y-4">
|
<div id="webhook_fields" class="hidden space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="webhook_format" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Webhook Format
|
||||||
|
</label>
|
||||||
|
<select id="webhook_format"
|
||||||
|
name="webhook_format"
|
||||||
|
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||||
|
onchange="updateWebhookPlaceholder()">
|
||||||
|
<option value="generic">Generic (n8n/Zapier/Make)</option>
|
||||||
|
<option value="google_chat">Google Chat</option>
|
||||||
|
<option value="simple_text">Simple Text ({"text":"..."})</option>
|
||||||
|
</select>
|
||||||
|
<p id="webhook_format_help" class="mt-1.5 text-xs text-gray-500">
|
||||||
|
Choose the payload format for your webhook endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
Webhook URL
|
Webhook URL <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="generic_webhook_url"
|
id="generic_webhook_url"
|
||||||
@@ -366,10 +390,26 @@ ob_start();
|
|||||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
|
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
|
||||||
placeholder="https://example.com/webhook-endpoint"
|
placeholder="https://example.com/webhook-endpoint"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<p class="mt-1.5 text-xs text-gray-500">
|
<p id="webhook_url_help" class="mt-1.5 text-xs text-gray-500">
|
||||||
Will receive JSON payload compatible with n8n/Zapier/Make.
|
Will receive JSON payload compatible with n8n/Zapier/Make.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Chat specific help -->
|
||||||
|
<div id="google_chat_help" class="hidden bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-medium text-green-800 flex items-center mb-2">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
Google Chat Setup Instructions
|
||||||
|
</h4>
|
||||||
|
<ol class="text-xs text-green-700 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Open your Google Chat space</li>
|
||||||
|
<li>Click the space name → <strong>Apps & integrations</strong></li>
|
||||||
|
<li>Click <strong>+ Add webhooks</strong></li>
|
||||||
|
<li>Enter a name (e.g., "Domain Monitor") and optionally add an avatar</li>
|
||||||
|
<li>Click <strong>Save</strong> and copy the webhook URL</li>
|
||||||
|
<li>Paste the URL above (starts with <code>https://chat.googleapis.com/v1/spaces/...</code>)</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
@@ -741,6 +781,9 @@ function testChannel(channelType, existingConfig = null) {
|
|||||||
break;
|
break;
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
formData.append('webhook_url', existingConfig.webhook_url);
|
formData.append('webhook_url', existingConfig.webhook_url);
|
||||||
|
if (existingConfig.format) {
|
||||||
|
formData.append('webhook_format', existingConfig.format);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'pushover':
|
case 'pushover':
|
||||||
formData.append('pushover_api_token', existingConfig.api_token);
|
formData.append('pushover_api_token', existingConfig.api_token);
|
||||||
@@ -774,6 +817,10 @@ function testChannel(channelType, existingConfig = null) {
|
|||||||
break;
|
break;
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
formData.append('webhook_url', document.getElementById('generic_webhook_url').value);
|
formData.append('webhook_url', document.getElementById('generic_webhook_url').value);
|
||||||
|
const webhookFormat = document.getElementById('webhook_format');
|
||||||
|
if (webhookFormat && webhookFormat.value) {
|
||||||
|
formData.append('webhook_format', webhookFormat.value);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'pushover':
|
case 'pushover':
|
||||||
formData.append('pushover_api_token', document.getElementById('pushover_api_token').value);
|
formData.append('pushover_api_token', document.getElementById('pushover_api_token').value);
|
||||||
@@ -894,6 +941,37 @@ function showToast(message, type = 'info') {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update webhook placeholder and help text based on selected format
|
||||||
|
function updateWebhookPlaceholder() {
|
||||||
|
const format = document.getElementById('webhook_format').value;
|
||||||
|
const urlInput = document.getElementById('generic_webhook_url');
|
||||||
|
const urlHelp = document.getElementById('webhook_url_help');
|
||||||
|
const formatHelp = document.getElementById('webhook_format_help');
|
||||||
|
const googleChatHelp = document.getElementById('google_chat_help');
|
||||||
|
|
||||||
|
// Update placeholder and help based on format
|
||||||
|
switch(format) {
|
||||||
|
case 'google_chat':
|
||||||
|
urlInput.placeholder = 'https://chat.googleapis.com/v1/spaces/XXXXX/messages?key=...';
|
||||||
|
urlHelp.innerHTML = '<i class="fas fa-info-circle text-green-500 mr-1"></i>Paste your Google Chat webhook URL from space settings.';
|
||||||
|
formatHelp.textContent = 'Sends messages in Google Chat format with rich cards for domain alerts.';
|
||||||
|
googleChatHelp.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'simple_text':
|
||||||
|
urlInput.placeholder = 'https://example.com/webhook-endpoint';
|
||||||
|
urlHelp.innerHTML = 'Sends simple JSON payload: <code class="bg-gray-100 px-1 rounded">{"text":"message"}</code>';
|
||||||
|
formatHelp.textContent = 'Compatible with services expecting simple text payloads.';
|
||||||
|
googleChatHelp.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
default: // generic
|
||||||
|
urlInput.placeholder = 'https://example.com/webhook-endpoint';
|
||||||
|
urlHelp.textContent = 'Will receive JSON payload compatible with n8n/Zapier/Make.';
|
||||||
|
formatHelp.textContent = 'Sends structured JSON with event type, message, data, and timestamp.';
|
||||||
|
googleChatHelp.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
Reference in New Issue
Block a user