From 9b20dc5f271c6672cfbd8702e0320fa6e88d5a22 Mon Sep 17 00:00:00 2001 From: Ryahn Date: Fri, 30 May 2025 16:48:03 -0500 Subject: [PATCH 1/6] feat(extensions): add Webhook extension for automated RSS notifications - Implements webhook notifications for RSS entries matching keywords - Supports pattern matching in titles, feeds, authors, and content - Configurable HTTP methods and request formats - Comprehensive error handling and logging - Resolves #1513 --- xExtension-Webhook/LICENSE | 19 ++ xExtension-Webhook/README.md | 72 +++++ xExtension-Webhook/configure.phtml | 199 +++++++++++++ xExtension-Webhook/extension.php | 433 +++++++++++++++++++++++++++++ xExtension-Webhook/i18n/en/ext.php | 45 +++ xExtension-Webhook/metadata.json | 8 + xExtension-Webhook/request.php | 296 ++++++++++++++++++++ 7 files changed, 1072 insertions(+) create mode 100644 xExtension-Webhook/LICENSE create mode 100644 xExtension-Webhook/README.md create mode 100644 xExtension-Webhook/configure.phtml create mode 100644 xExtension-Webhook/extension.php create mode 100644 xExtension-Webhook/i18n/en/ext.php create mode 100644 xExtension-Webhook/metadata.json create mode 100644 xExtension-Webhook/request.php diff --git a/xExtension-Webhook/LICENSE b/xExtension-Webhook/LICENSE new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/xExtension-Webhook/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md new file mode 100644 index 0000000..4fd6b2f --- /dev/null +++ b/xExtension-Webhook/README.md @@ -0,0 +1,72 @@ +# FreshRSS Webhook + +A FreshRSS extension for sending custom webhooks when new article appears (and matches custom criteria) + +## Installation / Usage + +Please follow official README: https://github.com/FreshRSS/Extensions?tab=readme-ov-file + +## Documentation + +You can define keywords to be used for matching new incoming article. +When article contains at least one defined keyword, the webhook will be sent. + +Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined. These must be able to be evaluated using the PHP function `preg_match`. + +Examples: + +```text +some keyword +important +/\p{Latin}/i +``` + +In addition, you can choose whether the matched articles will not be inserted into the database or whether they will be inserted into the database but marked as read (default). + +## How it works + +``` +┌──────────────┐ ┌────────────────────────────────────┐ ┌───────┐ +│ │ │ FreshRSS │ │ │ +│ │ │ │ │ some │ +│ INTERNET │ │ ┌────────┐ ┌─────↓─────┐ │ │ │ +│ │ │ │FreshRSS│ │• Webhook •│ │ │service│ +│ │ │ │ core │ │ extension │ │ │ │ +└────┬─────────┘ └──┴──┬─────┴─────────┴─────┬─────┴──┘ └─────┬─┘ + │ │ │ │ + │ checks RSS │ │ │ + │ for new articles │ │ │ + │◄─────────────────────────┤ │ │ + │ │ │ if some new article │ + ├─────────────────────────►│ │ matches custom criteria │ + │ new articles ├────────────────────►│ │ + │ │ new articles ├────────────────────────►│ + │ │ │ HTTP request │ + │ │ │ │ + │ checks RSS │ │ │ + │ or new articles │ │ │ + │◄─────────────────────────┤ │ │ + │ │ │ if no new article │ + ├─────────────────────────►│ │ matches custom criteria │ + │ new articles ├────────────────────►│ no request will be sent │ + │ │ new articles │ │ + │ │ │ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +``` + +- for every new article that matches custom criteria new HTTP request will be sent + +- see also discussion: https://github.com/FreshRSS/FreshRSS/discussions/6480 + +## ⚠️ Limitations + +- currently only GET, POST and PUT methods are supported +- there is no validation for configuration +- it's not fully tested and translated yet + +## Special Thanks + +- inspired by extension [**FilterTitle**](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-FilterTitle) +by [@cn-tools](https://github.com/cn-tools) +- Linting fixes and updates by [@Ryahn](https://github.com/Ryahn) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 0000000..1325940 --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,199 @@ + + + + +
+ + +
+ ⚙️ +
+ + +
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + + + + + +
+ getSystemConfigurationValue('search_in_title') ? 'checked' : '' ?>> + + + getSystemConfigurationValue('search_in_feed') ? 'checked' : '' ?>> + + + getSystemConfigurationValue('search_in_authors') ? 'checked' : '' ?>> + + + getSystemConfigurationValue('search_in_content') ? 'checked' : '' ?>> + +
+
+
+ +
+ +
+ getSystemConfigurationValue('mark_as_read') ? 'checked' : '' ?>> +
+
+ +
+
+ + + +
+ 🌐 +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
__TITLE__
__URL__
__CONTENT__
__DATE__
__DATE_TIMESTAMP__
__AUTHORS__
__THUMBNAIL_URL__
__FEED__
__TAGS__
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ +
+
+
+ +
+ getWebhookBodyType() === 'json' ? 'checked' : '' ?>> + + + getWebhookBodyType() === 'form' ? 'checked' : '' ?>> + + +
+ + +
+
+
+
+ +
+
+ + +
+
+ + + +
+
+ +
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 0000000..7ae4a36 --- /dev/null +++ b/xExtension-Webhook/extension.php @@ -0,0 +1,433 @@ +"; + + /** + * Default HTTP headers for webhook requests + * + * @var string[] + */ + public array $webhook_headers = ["User-Agent: FreshRSS", "Content-Type: application/x-www-form-urlencoded"]; + + /** + * Default webhook request body template + * + * Supports placeholders like __TITLE__, __FEED__, __URL__, etc. + * + * @var string + */ + public string $webhook_body = '{ + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "created": "__DATE_TIMESTAMP__" +}'; + + /** + * Initialize the extension + * + * Registers translation files and hooks into FreshRSS entry processing. + * + * @return void + */ + #[\Override] + public function init(): void { + $this->registerTranslates(); + $this->registerHook("entry_before_insert", [$this, "processArticle"]); + } + + /** + * Handle configuration form submission + * + * Processes configuration form data, saves settings, and optionally + * sends a test webhook request. + * + * @return void + */ + public function handleConfigureAction(): void { + $this->registerTranslates(); + + if (Minz_Request::isPost()) { + $conf = [ + "keywords" => array_filter(Minz_Request::paramTextToArray("keywords")), + "search_in_title" => Minz_Request::paramString("search_in_title"), + "search_in_feed" => Minz_Request::paramString("search_in_feed"), + "search_in_authors" => Minz_Request::paramString("search_in_authors"), + "search_in_content" => Minz_Request::paramString("search_in_content"), + "mark_as_read" => (bool) Minz_Request::paramString("mark_as_read"), + "ignore_updated" => (bool) Minz_Request::paramString("ignore_updated"), + + "webhook_url" => Minz_Request::paramString("webhook_url"), + "webhook_method" => Minz_Request::paramString("webhook_method"), + "webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers")), + "webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")), + "webhook_body_type" => Minz_Request::paramString("webhook_body_type"), + "enable_logging" => (bool) Minz_Request::paramString("enable_logging"), + ]; + $this->setSystemConfiguration($conf); + $logsEnabled = $conf["enable_logging"]; + $this->logsEnabled = $conf["enable_logging"]; + + logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf)); + + if (Minz_Request::paramString("test_request")) { + try { + sendReq( + $conf["webhook_url"], + $conf["webhook_method"], + $conf["webhook_body_type"], + $conf["webhook_body"], + $conf["webhook_headers"], + $conf["enable_logging"], + "Test request from configuration" + ); + } catch (Throwable $err) { + logError($logsEnabled, "Test request failed: {$err->getMessage()}"); + } + } + } + } + + /** + * Process article and send webhook if patterns match + * + * Analyzes RSS entries against configured keyword patterns and sends + * webhook notifications for matching entries. Supports pattern matching + * in titles, feeds, authors, and content. + * + * @param FreshRSS_Entry $entry The RSS entry to process + * + * @return FreshRSS_Entry The processed entry (potentially marked as read) + */ + public function processArticle($entry): FreshRSS_Entry { + if (!is_object($entry)) { + return $entry; + } + + if ((bool)$this->getSystemConfigurationValue("ignore_updated") && $entry->isUpdated()) { + logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + return $entry; + } + + $searchInTitle = (bool)($this->getSystemConfigurationValue("search_in_title") ?? false); + $searchInFeed = (bool)($this->getSystemConfigurationValue("search_in_feed") ?? false); + $searchInAuthors = (bool)($this->getSystemConfigurationValue("search_in_authors") ?? false); + $searchInContent = (bool)($this->getSystemConfigurationValue("search_in_content") ?? false); + + $patterns = $this->getSystemConfigurationValue("keywords") ?? []; + $markAsRead = (bool)($this->getSystemConfigurationValue("mark_as_read") ?? false); + $logsEnabled = (bool)($this->getSystemConfigurationValue("enable_logging") ?? false); + $this->logsEnabled = $logsEnabled; + + // Validate patterns + if (!is_array($patterns) || empty($patterns)) { + logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings."); + return $entry; + } + + $title = "❗️NOT INITIALIZED"; + $link = "❗️NOT INITIALIZED"; + $additionalLog = ""; + + try { + $title = $entry->title(); + $link = $entry->link(); + + foreach ($patterns as $pattern) { + $matchFound = false; + + if ($searchInTitle && $this->isPatternFound("/{$pattern}/", $title)) { + logWarning($logsEnabled, "matched item by title ✔️ \"{$title}\" ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ title \"{$title}\" ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInFeed && is_object($entry->feed()) && $this->isPatternFound("/{$pattern}/", $entry->feed()->name())) { + logWarning($logsEnabled, "matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInAuthors && $this->isPatternFound("/{$pattern}/", $entry->authors(true))) { + logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInContent && $this->isPatternFound("/{$pattern}/", $entry->content())) { + logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"; + $matchFound = true; + } + + if ($matchFound) { + break; + } + } + + if ($markAsRead) { + $entry->_isRead($markAsRead); + } + + // Only send webhook if a pattern was matched + if (!empty($additionalLog)) { + $this->sendArticle($entry, $additionalLog); + } + + } catch (Throwable $err) { + logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}"); + } + + return $entry; + } + + /** + * Send article data via webhook + * + * Prepares and sends webhook notification with article data. + * Replaces template placeholders with actual entry values. + * + * @param FreshRSS_Entry $entry The RSS entry to send + * @param string $additionalLog Additional context for logging + * + * @return void + */ + private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { + try { + $bodyStr = (string)$this->getSystemConfigurationValue("webhook_body"); + + // Replace placeholders with actual values + $replacements = [ + "__TITLE__" => $this->toSafeJsonStr($entry->title()), + "__FEED__" => $this->toSafeJsonStr($entry->feed()->name()), + "__URL__" => $this->toSafeJsonStr($entry->link()), + "__CONTENT__" => $this->toSafeJsonStr($entry->content()), + "__DATE__" => $this->toSafeJsonStr($entry->date()), + "__DATE_TIMESTAMP__" => $this->toSafeJsonStr($entry->date(true)), + "__AUTHORS__" => $this->toSafeJsonStr($entry->authors(true)), + "__TAGS__" => $this->toSafeJsonStr($entry->tags(true)), + ]; + + $bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr); + + sendReq( + (string)$this->getSystemConfigurationValue("webhook_url"), + (string)$this->getSystemConfigurationValue("webhook_method"), + (string)$this->getSystemConfigurationValue("webhook_body_type"), + $bodyStr, + (array)$this->getSystemConfigurationValue("webhook_headers"), + (bool)$this->getSystemConfigurationValue("enable_logging"), + $additionalLog, + ); + } catch (Throwable $err) { + logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}"); + } + } + + /** + * Convert string/int to safe JSON string + * + * Sanitizes input values for safe inclusion in JSON payloads + * by removing quotes and decoding HTML entities. + * + * @param string|int $str Input value to sanitize + * + * @return string Sanitized string safe for JSON inclusion + */ + private function toSafeJsonStr(string|int $str): string { + if (is_numeric($str)) { + return (string)$str; + } + + // Remove quotes and decode HTML entities + return str_replace('"', '', html_entity_decode((string)$str)); + } + + /** + * Check if pattern is found in text + * + * Attempts regex matching first, then falls back to simple string search. + * Handles regex errors gracefully and logs issues. + * + * @param string $pattern Search pattern (may include regex delimiters) + * @param string $text Text to search in + * + * @return bool True if pattern is found, false otherwise + */ + private function isPatternFound(string $pattern, string $text): bool { + if (empty($text) || empty($pattern)) { + return false; + } + + try { + // Try regex match first + if (preg_match($pattern, $text) === 1) { + return true; + } + + // Fallback to string search (remove regex delimiters) + $cleanPattern = trim($pattern, '/'); + return str_contains($text, $cleanPattern); + + } catch (Throwable $err) { + logError($this->logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err->getMessage()}"); + return false; + } + } + + /** + * Get keywords configuration as formatted string + * + * Returns the configured keywords as a newline-separated string + * for display in the configuration form. + * + * @return string Keywords separated by newlines + */ + public function getKeywordsData(): string { + $keywords = $this->getSystemConfigurationValue("keywords"); + return implode(PHP_EOL, is_array($keywords) ? $keywords : []); + } + + /** + * Get webhook headers configuration as formatted string + * + * Returns the configured HTTP headers as a newline-separated string + * for display in the configuration form. + * + * @return string HTTP headers separated by newlines + */ + public function getWebhookHeaders(): string { + $headers = $this->getSystemConfigurationValue("webhook_headers"); + return implode( + PHP_EOL, + is_array($headers) ? $headers : ($this->webhook_headers ?? []), + ); + } + + /** + * Get configured webhook URL + * + * Returns the configured webhook URL or the default if none is set. + * + * @return string The webhook URL + */ + public function getWebhookUrl(): string { + return (string)($this->getSystemConfigurationValue("webhook_url") ?? $this->webhook_url); + } + + /** + * Get configured webhook body template + * + * Returns the configured webhook body template or the default if none is set. + * + * @return string The webhook body template + */ + public function getWebhookBody(): string { + $body = $this->getSystemConfigurationValue("webhook_body"); + return (!$body || $body === "") ? $this->webhook_body : (string)$body; + } + + /** + * Get configured webhook body type + * + * Returns the configured body type (json/form) or the default if none is set. + * + * @return string The webhook body type + */ + public function getWebhookBodyType(): string { + return (string)($this->getSystemConfigurationValue("webhook_body_type") ?? $this->webhook_body_type->value); + } +} + +/** + * Backward compatibility alias for logWarning function + * + * @deprecated Use logWarning() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @return void + */ +function _LOG(bool $logEnabled, $data): void { + logWarning($logEnabled, $data); +} + +/** + * Backward compatibility alias for logError function + * + * @deprecated Use logError() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @return void + */ +function _LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 0000000..859007a --- /dev/null +++ b/xExtension-Webhook/i18n/en/ext.php @@ -0,0 +1,45 @@ + array( + 'event_settings' => 'Event settings', + 'show_hide' => 'show/hide', + 'webhook_settings' => 'Webhook settings', + 'more_options' => 'More options (headers, format,…):', + 'save_and_send_test_req' => 'Save and send test request', + 'description' => 'Webhooks allow external services to be notified when certain events happen.\nWhen the specified events happen, we\'ll send a HTTP request (usually POST) to the URL you provide.', + 'keywords' => 'Keywords in the new article', + 'search_in' => 'Search in article\'s:', + 'search_in_title' => 'title', + 'search_in_feed' => 'feed', + 'search_in_content' => 'content', + 'search_in_all' => 'all', + 'search_in_none' => 'none', + 'keywords_description' => 'Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined (they are evaluated using the PHP function preg_match).', + 'search_in_title_label' => '🪧 title *   ', + 'search_in_feed_label' => '💼 feed   ', + 'search_in_authors_label' => '👥 authors   ', + 'search_in_content_label' => '📄 content   ', + 'mark_as_read' => 'Mark as read', + 'mark_as_read_description' => 'Mark the article as read after sending the webhook.', + 'mark_as_read_label' => 'Mark as read', + 'http_body' => 'HTTP Body', + 'http_body_description' => 'Must be valid JSON or form data (x-www-form-urlencoded)', + 'http_body_placeholder_summary' => 'You can use special placeholders that will be replaced by the actual values:', + 'http_body_placeholder_title' => 'Placeholder', + 'http_body_placeholder_description' => 'Description', + 'http_body_placeholder_title_description' => 'Article title', + 'http_body_placeholder_url_description' => 'HTML-encoded link of the article', + 'http_body_placeholder_content_description' => 'Content of the article (HTML format)', + 'http_body_placeholder_authors_description' => 'Authors of the article', + 'http_body_placeholder_feed_description' => 'Feed of the article', + 'http_body_placeholder_tags_description' => 'Article tags (string, separated by " #")', + 'http_body_placeholder_date_description' => 'Date of the article (string)', + 'http_body_placeholder_date_timestamp_description' => 'Date of the article as timestamp (number)', + 'http_body_placeholder_thumbnail_url_description' => 'Thumbnail (image) URL', + 'webhook_headers' => 'HTTP Headers
(one per line)', + 'http_body_type' => 'HTTP Body type', + 'more_info' => 'More info:', + 'more_info_description' => 'When header contains Content-type: application/x-www-form-urlencoded the keys and values are encoded in key-value tuples separated by "&", with a "=" between the key and the value. Non-alphanumeric characters in both keys and values are URL encoded' + ), +); diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json new file mode 100644 index 0000000..6cba9dd --- /dev/null +++ b/xExtension-Webhook/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "Webhook", + "author": "Lukas Melega, Ryahn", + "description": "Send custom webhook when new article appears (and matches custom criteria)", + "version": "0.1.1", + "entrypoint": "Webhook", + "type": "system" +} diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php new file mode 100644 index 0000000..66dd434 --- /dev/null +++ b/xExtension-Webhook/request.php @@ -0,0 +1,296 @@ +getMessage()} | URL: {$url} | Body: {$body}"); + throw $err; + } finally { + curl_close($ch); + } +} + +/** + * Configure cURL HTTP method settings + * + * Sets the appropriate cURL options based on the HTTP method. + * + * @param CurlHandle $ch The cURL handle + * @param string $method HTTP method in uppercase + * + * @return void + */ +function configureHttpMethod(CurlHandle $ch, string $method): void { + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + switch ($method) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + break; + case 'PUT': + curl_setopt($ch, CURLOPT_PUT, true); + break; + case 'GET': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); + break; + case 'DELETE': + case 'PATCH': + case 'OPTIONS': + case 'HEAD': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + break; + } +} + +/** + * Process HTTP body based on content type + * + * Converts the request body to the appropriate format based on the body type. + * Supports JSON and form-encoded data. + * + * @param string $body Raw body content as JSON string + * @param string $bodyType Content type ('json' or 'form') + * @param string $method HTTP method + * @param bool $logEnabled Whether logging is enabled + * + * @throws JsonException When JSON processing fails + * @throws InvalidArgumentException When unsupported body type is provided + * + * @return string|null Processed body content or null if no body needed + */ +function processHttpBody(string $body, string $bodyType, string $method, bool $logEnabled): ?string { + if (empty($body) || $method === 'GET') { + return null; + } + + try { + $bodyObject = json_decode($body, true, 256, JSON_THROW_ON_ERROR); + + return match ($bodyType) { + 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR), + 'form' => http_build_query($bodyObject ?? []), + default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}") + }; + } catch (JsonException $err) { + logError($logEnabled, "JSON processing error: {$err->getMessage()} | Body: {$body}"); + throw $err; + } +} + +/** + * Configure HTTP headers for the request + * + * Sets appropriate Content-Type headers if none are provided, + * based on the body type. + * + * @param string[] $headers Array of custom headers + * @param string $bodyType Content type ('json' or 'form') + * + * @return string[] Final array of headers to use + */ +function configureHeaders(array $headers, string $bodyType): array { + if (empty($headers)) { + return match ($bodyType) { + 'form' => ['Content-Type: application/x-www-form-urlencoded'], + 'json' => ['Content-Type: application/json'], + default => [] + }; + } + + return $headers; +} + +/** + * Log the outgoing HTTP request details + * + * Logs comprehensive information about the request being sent, + * including URL, method, body, and headers. + * + * @param bool $logEnabled Whether logging is enabled + * @param string $additionalLog Additional context information + * @param string $method HTTP method + * @param string $url Target URL + * @param string $bodyType Content type + * @param string|null $body Processed request body + * @param string[] $headers Array of HTTP headers + * + * @return void + */ +function logRequest( + bool $logEnabled, + string $additionalLog, + string $method, + string $url, + string $bodyType, + ?string $body, + array $headers +): void { + if (!$logEnabled) { + return; + } + + $cleanUrl = urldecode($url); + $cleanBody = $body ? str_replace('\/', '/', $body) : ''; + $headersJson = json_encode($headers); + + $logMessage = trim("{$additionalLog} ♦♦ sendReq ⏩ {$method}: {$cleanUrl} ♦♦ {$bodyType} ♦♦ {$cleanBody} ♦♦ {$headersJson}"); + + logWarning($logEnabled, $logMessage); +} + +/** + * Execute cURL request and handle response + * + * Executes the configured cURL request and handles both success + * and error responses with appropriate logging. + * + * @param CurlHandle $ch The configured cURL handle + * @param bool $logEnabled Whether logging is enabled + * + * @throws RuntimeException When cURL execution fails + * + * @return void + */ +function executeRequest(CurlHandle $ch, bool $logEnabled): void { + $response = curl_exec($ch); + + if (curl_errno($ch)) { + $error = curl_error($ch); + logError($logEnabled, "cURL error: {$error}"); + throw new RuntimeException("cURL error: {$error}"); + } + + $info = curl_getinfo($ch); + $httpCode = $info['http_code'] ?? 'unknown'; + + logWarning($logEnabled, "Response ✅ ({$httpCode}) {$response}"); +} + +/** + * Log warning message using FreshRSS logging system + * + * Safely logs warning messages through the FreshRSS Minz_Log system + * with proper class existence checking. + * + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log (will be converted to string) + * + * @return void + */ +function logWarning(bool $logEnabled, $data): void { + if ($logEnabled && class_exists('Minz_Log')) { + Minz_Log::warning("[WEBHOOK] " . $data); + } +} + +/** + * Log error message using FreshRSS logging system + * + * Safely logs error messages through the FreshRSS Minz_Log system + * with proper class existence checking. + * + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log (will be converted to string) + * + * @return void + */ +function logError(bool $logEnabled, $data): void { + if ($logEnabled && class_exists('Minz_Log')) { + Minz_Log::error("[WEBHOOK]❌ " . $data); + } +} + +/** + * Backward compatibility alias for logWarning function + * + * @deprecated Use logWarning() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @return void + */ +function LOG_WARN(bool $logEnabled, $data): void { + logWarning($logEnabled, $data); +} + +/** + * Backward compatibility alias for logError function + * + * @deprecated Use logError() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @return void + */ +function LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +} From 589e54ae32a21a7b3fe997a09463edf28dfb1db7 Mon Sep 17 00:00:00 2001 From: Ryahn Date: Fri, 30 May 2025 16:49:16 -0500 Subject: [PATCH 2/6] docs(webhook): update README with comprehensive documentation and examples --- xExtension-Webhook/README.md | 262 ++++++++++++++++++++++++++++------- 1 file changed, 209 insertions(+), 53 deletions(-) diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index 4fd6b2f..71bf1f4 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -1,72 +1,228 @@ -# FreshRSS Webhook +# FreshRSS Webhook Extension -A FreshRSS extension for sending custom webhooks when new article appears (and matches custom criteria) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![FreshRSS](https://img.shields.io/badge/FreshRSS-1.20.0+-green.svg)](https://freshrss.org/) -## Installation / Usage +A powerful FreshRSS extension that automatically sends webhook notifications when RSS entries match specified keywords. Perfect for integrating with Discord, Slack, Telegram, or any service that supports webhooks. -Please follow official README: https://github.com/FreshRSS/Extensions?tab=readme-ov-file +## 🚀 Features -## Documentation +- **Automated Notifications**: Automatically sends webhooks when new RSS entries match your keywords +- **Flexible Pattern Matching**: Search in titles, feed names, authors, or content +- **Multiple HTTP Methods**: Supports GET, POST, PUT, DELETE, PATCH, OPTIONS, and HEAD +- **Configurable Formats**: Send data as JSON or form-encoded +- **Template System**: Customizable webhook payloads with placeholders +- **Comprehensive Logging**: Detailed logging for debugging and monitoring +- **Error Handling**: Robust error handling with graceful fallbacks +- **Test Functionality**: Built-in test feature to verify webhook configuration -You can define keywords to be used for matching new incoming article. -When article contains at least one defined keyword, the webhook will be sent. +## 📋 Requirements -Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined. These must be able to be evaluated using the PHP function `preg_match`. +- FreshRSS 1.20.0 or later +- PHP 8.1 or later +- cURL extension enabled -Examples: +## 🔧 Installation -```text -some keyword -important -/\p{Latin}/i +1. Download the extension files +2. Upload the `xExtension-Webhook` folder to your FreshRSS `extensions` directory +3. Enable the extension in FreshRSS admin panel under Extensions + +## ⚙️ Configuration + +### Basic Setup + +1. Go to **Administration** → **Extensions** → **Webhook** +2. Configure the following settings: + +#### Keywords +Enter keywords to match against RSS entries (one per line): +``` +breaking news +security alert +your-project-name ``` -In addition, you can choose whether the matched articles will not be inserted into the database or whether they will be inserted into the database but marked as read (default). +#### Search Options +- **Search in Title**: Match keywords in article titles +- **Search in Feed**: Match keywords in feed names +- **Search in Authors**: Match keywords in author names +- **Search in Content**: Match keywords in article content -## How it works +#### Webhook Settings +- **Webhook URL**: Your webhook endpoint URL +- **HTTP Method**: Choose from GET, POST, PUT, DELETE, etc. +- **Body Type**: JSON or Form-encoded +- **Headers**: Custom HTTP headers (one per line) -``` -┌──────────────┐ ┌────────────────────────────────────┐ ┌───────┐ -│ │ │ FreshRSS │ │ │ -│ │ │ │ │ some │ -│ INTERNET │ │ ┌────────┐ ┌─────↓─────┐ │ │ │ -│ │ │ │FreshRSS│ │• Webhook •│ │ │service│ -│ │ │ │ core │ │ extension │ │ │ │ -└────┬─────────┘ └──┴──┬─────┴─────────┴─────┬─────┴──┘ └─────┬─┘ - │ │ │ │ - │ checks RSS │ │ │ - │ for new articles │ │ │ - │◄─────────────────────────┤ │ │ - │ │ │ if some new article │ - ├─────────────────────────►│ │ matches custom criteria │ - │ new articles ├────────────────────►│ │ - │ │ new articles ├────────────────────────►│ - │ │ │ HTTP request │ - │ │ │ │ - │ checks RSS │ │ │ - │ or new articles │ │ │ - │◄─────────────────────────┤ │ │ - │ │ │ if no new article │ - ├─────────────────────────►│ │ matches custom criteria │ - │ new articles ├────────────────────►│ no request will be sent │ - │ │ new articles │ │ - │ │ │ │ - │ │ │ │ - ▼ ▼ ▼ ▼ +### Webhook Body Template + +Customize the webhook payload using placeholders: + +```json +{ + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "content": "__CONTENT__", + "date": "__DATE__", + "timestamp": "__DATE_TIMESTAMP__", + "authors": "__AUTHORS__", + "tags": "__TAGS__" +} ``` -- for every new article that matches custom criteria new HTTP request will be sent +#### Available Placeholders -- see also discussion: https://github.com/FreshRSS/FreshRSS/discussions/6480 +| Placeholder | Description | +|-------------|-------------| +| `__TITLE__` | Article title | +| `__FEED__` | Feed name | +| `__URL__` | Article URL | +| `__CONTENT__` | Article content | +| `__DATE__` | Publication date | +| `__DATE_TIMESTAMP__` | Unix timestamp | +| `__AUTHORS__` | Article authors | +| `__TAGS__` | Article tags | -## ⚠️ Limitations +## 🎯 Use Cases -- currently only GET, POST and PUT methods are supported -- there is no validation for configuration -- it's not fully tested and translated yet +### Discord Webhook +```json +{ + "content": "New article: **__TITLE__**", + "embeds": [{ + "title": "__TITLE__", + "url": "__URL__", + "description": "__CONTENT__", + "color": 3447003, + "footer": { + "text": "__FEED__" + } + }] +} +``` -## Special Thanks +### Slack Webhook +```json +{ + "text": "New article from __FEED__", + "attachments": [{ + "title": "__TITLE__", + "title_link": "__URL__", + "text": "__CONTENT__", + "color": "good" + }] +} +``` -- inspired by extension [**FilterTitle**](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-FilterTitle) -by [@cn-tools](https://github.com/cn-tools) -- Linting fixes and updates by [@Ryahn](https://github.com/Ryahn) +### Custom API Integration +```json +{ + "event": "new_article", + "data": { + "title": "__TITLE__", + "url": "__URL__", + "feed": "__FEED__", + "timestamp": "__DATE_TIMESTAMP__" + } +} +``` + +## 🔍 Pattern Matching + +The extension supports both regex patterns and simple string matching: + +### Regex Patterns +``` +/security.*/i +/\b(urgent|critical)\b/i +``` + +### Simple Strings +``` +breaking news +security alert +``` + +## 🛠️ Advanced Configuration + +### Custom Headers +Add authentication or custom headers: +``` +Authorization: Bearer your-token-here +X-Custom-Header: custom-value +User-Agent: FreshRSS-Webhook/1.0 +``` + +### Error Handling +- Failed webhooks are logged for debugging +- Network timeouts are handled gracefully +- Invalid configurations are validated + +### Performance +- Only sends webhooks when patterns match +- Efficient pattern matching with fallbacks +- Minimal impact on RSS processing + +## 🐛 Troubleshooting + +### Common Issues + +**Webhooks not sending:** +- Check that keywords are configured +- Verify webhook URL is accessible +- Enable logging to see detailed information + +**Pattern not matching:** +- Test with simple string patterns first +- Check regex syntax if using regex patterns +- Verify search options are enabled + +**Authentication errors:** +- Check custom headers configuration +- Verify webhook endpoint accepts your format + +### Debugging + +Enable logging in the extension settings to see detailed information about: +- Pattern matching results +- HTTP request details +- Response codes and errors + +## 📝 Changelog + +### Version 0.1.1 +- Initial release +- Automated webhook notifications +- Pattern matching in multiple fields +- Configurable HTTP methods and formats +- Comprehensive error handling and logging +- Template-based webhook payloads + +## 🤝 Contributing + +This extension was developed to address [FreshRSS Issue #1513](https://github.com/FreshRSS/FreshRSS/issues/1513). + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Follow FreshRSS coding standards +4. Add tests for new functionality +5. Submit a pull request + +## 📄 License + +This extension is licensed under the [GNU Affero General Public License v3.0](LICENSE). + +## 🙏 Acknowledgments + +- FreshRSS development team for the excellent extension system +- Community members who requested and tested this feature +- Contributors to the original feature request + +## 📞 Support + +- [FreshRSS Documentation](https://freshrss.github.io/FreshRSS/) +- [GitHub Issues](https://github.com/FreshRSS/Extensions/issues) +- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions) From c26a0549c182af102c24434fc43f93f5629f6c43 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 1 Jun 2025 00:58:51 +0200 Subject: [PATCH 3/6] Uniform whitespace --- xExtension-Webhook/configure.phtml | 4 +- xExtension-Webhook/extension.php | 702 ++++++++++++++--------------- xExtension-Webhook/i18n/en/ext.php | 4 +- xExtension-Webhook/request.php | 312 ++++++------- 4 files changed, 511 insertions(+), 511 deletions(-) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index 1325940..d8161e8 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -77,7 +77,7 @@ declare(strict_types=1);
+
+
⚙️ @@ -16,7 +16,7 @@ declare(strict_types=1);
- +
@@ -32,22 +32,22 @@ declare(strict_types=1); getSystemConfigurationValue('search_in_title') ? 'checked' : '' ?>> + attributeBool('search_in_title') ? 'checked="checked"' : '' ?> /> getSystemConfigurationValue('search_in_feed') ? 'checked' : '' ?>> + attributeBool('search_in_feed') ? 'checked="checked"' : '' ?> /> getSystemConfigurationValue('search_in_authors') ? 'checked' : '' ?>> + attributeBool('search_in_authors') ? 'checked="checked"' : '' ?> /> getSystemConfigurationValue('search_in_content') ? 'checked' : '' ?>> + attributeBool('search_in_content') ? 'checked="checked"' : '' ?> /> @@ -59,7 +59,7 @@ declare(strict_types=1);
getSystemConfigurationValue('mark_as_read') ? 'checked' : '' ?>> + attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> />
@@ -78,14 +78,14 @@ declare(strict_types=1);
+ value="getWebhookUrl() ?>" />
@@ -190,8 +190,8 @@ declare(strict_types=1);
- - + +
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index cd5e8cd..598a989 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -110,6 +110,7 @@ class WebhookExtension extends Minz_Extension { * sends a test webhook request. * * @return void + * @throws Minz_PermissionDeniedException */ public function handleConfigureAction(): void { $this->registerTranslates(); @@ -164,6 +165,9 @@ class WebhookExtension extends Minz_Extension { * * @param FreshRSS_Entry $entry The RSS entry to process * + * @throws FreshRSS_Context_Exception + * @throws Minz_PermissionDeniedException + * * @return FreshRSS_Entry The processed entry (potentially marked as read) */ public function processArticle($entry): FreshRSS_Entry { @@ -171,19 +175,19 @@ class WebhookExtension extends Minz_Extension { return $entry; } - if ((bool)$this->getSystemConfigurationValue("ignore_updated") && $entry->isUpdated()) { + if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) { logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); return $entry; } - $searchInTitle = (bool)($this->getSystemConfigurationValue("search_in_title") ?? false); - $searchInFeed = (bool)($this->getSystemConfigurationValue("search_in_feed") ?? false); - $searchInAuthors = (bool)($this->getSystemConfigurationValue("search_in_authors") ?? false); - $searchInContent = (bool)($this->getSystemConfigurationValue("search_in_content") ?? false); + $searchInTitle = FreshRSS_Context::userConf()->attributeBool('search_in_title') ?? false; + $searchInFeed = FreshRSS_Context::userConf()->attributeBool('search_in_feed') ?? false; + $searchInAuthors = FreshRSS_Context::userConf()->attributeBool('search_in_authors') ?? false; + $searchInContent = FreshRSS_Context::userConf()->attributeBool('search_in_content') ?? false; - $patterns = $this->getSystemConfigurationValue("keywords") ?? []; - $markAsRead = (bool)($this->getSystemConfigurationValue("mark_as_read") ?? false); - $logsEnabled = (bool)($this->getSystemConfigurationValue("enable_logging") ?? false); + $patterns = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + $markAsRead = FreshRSS_Context::userConf()->attributeBool('mark_as_read') ?? false; + $logsEnabled = FreshRSS_Context::userConf()->attributeBool('enable_logging') ?? false; $this->logsEnabled = $logsEnabled; // Validate patterns @@ -257,11 +261,13 @@ class WebhookExtension extends Minz_Extension { * @param FreshRSS_Entry $entry The RSS entry to send * @param string $additionalLog Additional context for logging * + * @throws Minz_PermissionDeniedException + * * @return void */ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { try { - $bodyStr = (string)$this->getSystemConfigurationValue("webhook_body"); + $bodyStr = FreshRSS_Context::userConf()->attributeString('webhook_body'); // Replace placeholders with actual values $replacements = [ @@ -278,12 +284,12 @@ class WebhookExtension extends Minz_Extension { $bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr); sendReq( - (string)$this->getSystemConfigurationValue("webhook_url"), - (string)$this->getSystemConfigurationValue("webhook_method"), - (string)$this->getSystemConfigurationValue("webhook_body_type"), + FreshRSS_Context::userConf()->attributeString('webhook_url'), + FreshRSS_Context::userConf()->attributeString('webhook_method'), + FreshRSS_Context::userConf()->attributeString('webhook_body_type'), $bodyStr, - (array)$this->getSystemConfigurationValue("webhook_headers"), - (bool)$this->getSystemConfigurationValue("enable_logging"), + FreshRSS_Context::userConf()->attributeArray('webhook_headers'), + FreshRSS_Context::userConf()->attributeBool('enable_logging'), $additionalLog, ); } catch (Throwable $err) { @@ -348,11 +354,13 @@ class WebhookExtension extends Minz_Extension { * Returns the configured keywords as a newline-separated string * for display in the configuration form. * + * @throws FreshRSS_Context_Exception + * * @return string Keywords separated by newlines */ public function getKeywordsData(): string { - $keywords = $this->getSystemConfigurationValue("keywords"); - return implode(PHP_EOL, is_array($keywords) ? $keywords : []); + $keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + return implode(PHP_EOL, $keywords); } /** @@ -361,10 +369,12 @@ class WebhookExtension extends Minz_Extension { * Returns the configured HTTP headers as a newline-separated string * for display in the configuration form. * + * @throws FreshRSS_Context_Exception + * * @return string HTTP headers separated by newlines */ public function getWebhookHeaders(): string { - $headers = $this->getSystemConfigurationValue("webhook_headers"); + $headers = FreshRSS_Context::userConf()->attributeArray('webhook_headers'); return implode( PHP_EOL, is_array($headers) ? $headers : ($this->webhook_headers ?? []), @@ -376,10 +386,12 @@ class WebhookExtension extends Minz_Extension { * * Returns the configured webhook URL or the default if none is set. * + * @throws FreshRSS_Context_Exception + * * @return string The webhook URL */ public function getWebhookUrl(): string { - return (string)($this->getSystemConfigurationValue("webhook_url") ?? $this->webhook_url); + return FreshRSS_Context::userConf()->attributeString('webhook_url') ?? $this->webhook_url; } /** @@ -387,11 +399,13 @@ class WebhookExtension extends Minz_Extension { * * Returns the configured webhook body template or the default if none is set. * + * @throws FreshRSS_Context_Exception + * * @return string The webhook body template */ public function getWebhookBody(): string { - $body = $this->getSystemConfigurationValue("webhook_body"); - return (!$body || $body === "") ? $this->webhook_body : (string)$body; + $body = FreshRSS_Context::userConf()->attributeString('webhook_body'); + return ($body === null || $body === '') ? $this->webhook_body : $body; } /** @@ -399,10 +413,12 @@ class WebhookExtension extends Minz_Extension { * * Returns the configured body type (json/form) or the default if none is set. * + * @throws FreshRSS_Context_Exception + * * @return string The webhook body type */ public function getWebhookBodyType(): string { - return (string)($this->getSystemConfigurationValue("webhook_body_type") ?? $this->webhook_body_type->value); + return FreshRSS_Context::userConf()->attributeString('webhook_body_type') ?? $this->webhook_body_type->value; } } @@ -413,6 +429,8 @@ class WebhookExtension extends Minz_Extension { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * + * @throws Minz_PermissionDeniedException + * * @return void */ function _LOG(bool $logEnabled, $data): void { @@ -426,6 +444,8 @@ function _LOG(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * + * @throws Minz_PermissionDeniedException + * * @return void */ function _LOG_ERR(bool $logEnabled, $data): void { diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index 56f93ab..5a4143d 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -18,6 +18,7 @@ declare(strict_types=1); * * @throws InvalidArgumentException When invalid parameters are provided * @throws JsonException When JSON encoding/decoding fails + * @throws Minz_PermissionDeniedException * @throws RuntimeException When cURL operations fail * * @return void @@ -124,6 +125,7 @@ function configureHttpMethod(CurlHandle $ch, string $method): void { * * @throws JsonException When JSON processing fails * @throws InvalidArgumentException When unsupported body type is provided + * @throws Minz_PermissionDeniedException * * @return string|null Processed body content or null if no body needed */ @@ -183,6 +185,8 @@ function configureHeaders(array $headers, string $bodyType): array { * @param string|null $body Processed request body * @param string[] $headers Array of HTTP headers * + * @throws Minz_PermissionDeniedException + * * @return void */ function logRequest( @@ -217,6 +221,7 @@ function logRequest( * @param bool $logEnabled Whether logging is enabled * * @throws RuntimeException When cURL execution fails + * @throws Minz_PermissionDeniedException * * @return void */ @@ -244,6 +249,8 @@ function executeRequest(CurlHandle $ch, bool $logEnabled): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log (will be converted to string) * + * @throws Minz_PermissionDeniedException + * * @return void */ function logWarning(bool $logEnabled, $data): void { @@ -261,6 +268,8 @@ function logWarning(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log (will be converted to string) * + * @throws Minz_PermissionDeniedException + * * @return void */ function logError(bool $logEnabled, $data): void { @@ -276,6 +285,8 @@ function logError(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * + * @throws Minz_PermissionDeniedException + * * @return void */ function LOG_WARN(bool $logEnabled, $data): void { @@ -289,6 +300,8 @@ function LOG_WARN(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * + * @throws Minz_PermissionDeniedException + * * @return void */ function LOG_ERR(bool $logEnabled, $data): void {