diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index 5a319f0..ac3771f 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -591,7 +591,26 @@ class NotificationGroupController extends Controller if (!str_starts_with($webhookUrl, 'https://') && !str_starts_with($webhookUrl, 'http://')) { 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: return null; diff --git a/app/Services/Channels/WebhookChannel.php b/app/Services/Channels/WebhookChannel.php index fdeaa60..60f0ce6 100644 --- a/app/Services/Channels/WebhookChannel.php +++ b/app/Services/Channels/WebhookChannel.php @@ -10,6 +10,11 @@ class WebhookChannel implements NotificationChannelInterface private Client $httpClient; 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() { $this->httpClient = new Client(['timeout' => 10]); @@ -23,18 +28,13 @@ class WebhookChannel implements NotificationChannelInterface return false; } - // Build a sane, generic JSON payload for automation tools (n8n, Zapier, etc.) - $payload = [ - 'event' => 'domain_expiration_alert', - 'message' => $message, - 'data' => $data, - 'sent_at' => date('c') - ]; + $format = $config['format'] ?? self::FORMAT_GENERIC; + $payload = $this->buildPayload($format, $message, $data); try { $response = $this->httpClient->post($url, [ 'headers' => [ - 'Content-Type' => 'application/json' + 'Content-Type' => 'application/json; charset=UTF-8' ], 'json' => $payload ]); @@ -43,24 +43,223 @@ class WebhookChannel implements NotificationChannelInterface $ok = $status >= 200 && $status < 300; if ($ok) { $this->logger->info('Webhook sent successfully', [ - 'url' => $url, + 'url' => $this->maskUrl($url), + 'format' => $format, 'status' => $status ]); } else { + $responseBody = (string) $response->getBody(); $this->logger->error('Webhook responded with non-2xx', [ - 'url' => $url, - 'status' => $status + 'url' => $this->maskUrl($url), + 'format' => $format, + 'status' => $status, + 'response_body' => $this->truncate($responseBody, 1000), + 'payload_preview' => $this->getPayloadPreview($payload) ]); } 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) { $this->logger->error('Webhook send failed', [ - 'url' => $url, - 'exception' => $e->getMessage() + 'url' => $this->maskUrl($url), + 'format' => $format, + 'exception' => $e->getMessage(), + 'payload_preview' => $this->getPayloadPreview($payload) ]); 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)]; + } } diff --git a/app/Views/groups/edit.php b/app/Views/groups/edit.php index 3549302..cebc6f5 100644 --- a/app/Views/groups/edit.php +++ b/app/Views/groups/edit.php @@ -108,6 +108,14 @@ ob_start(); echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A'); } elseif ($channel['channel_type'] === 'pushover') { 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 { echo "Webhook configured"; } @@ -356,9 +364,25 @@ ob_start();
+ Choose the payload format for your webhook endpoint. +
++
Will receive JSON payload compatible with n8n/Zapier/Make.
https://chat.googleapis.com/v1/spaces/...){"text":"message"}';
+ 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;
+ }
+}