From 9d1836df3a4d4fc56c2cdcd7ca60ef1dac1031ba Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Tue, 14 Apr 2026 20:24:43 +0000 Subject: [PATCH 01/15] Copy of Ryahn:1513-Share-To-Webhook --- xExtension-Webhook/LICENSE | 19 ++ xExtension-Webhook/README.MD | 242 +++++++++++++++ xExtension-Webhook/configure.phtml | 199 +++++++++++++ xExtension-Webhook/extension.php | 453 +++++++++++++++++++++++++++++ xExtension-Webhook/i18n/en/ext.php | 45 +++ xExtension-Webhook/metadata.json | 8 + xExtension-Webhook/request.php | 309 ++++++++++++++++++++ 7 files changed, 1275 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 00000000..6802bc4b --- /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. \ No newline at end of file diff --git a/xExtension-Webhook/README.MD b/xExtension-Webhook/README.MD new file mode 100644 index 00000000..1af23118 --- /dev/null +++ b/xExtension-Webhook/README.MD @@ -0,0 +1,242 @@ +# FreshRSS Webhook Extension + +[![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/) + +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. + +## 🚀 Features + +- **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 + +## 📋 Requirements + +- FreshRSS 1.20.0 or later +- PHP 8.1 or later +- cURL extension enabled + +## 🔧 Installation + +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): + +```text +breaking news +security alert +your-project-name +``` + +#### 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 + +#### 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) + +### 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__" +} +``` + +#### Available Placeholders + +| 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 | + +## 🎯 Use Cases + +### Discord Webhook + +```json +{ + "content": "New article: **__TITLE__**", + "embeds": [{ + "title": "__TITLE__", + "url": "__URL__", + "description": "__CONTENT__", + "color": 3447003, + "footer": { + "text": "__FEED__" + } + }] +} +``` + +### Slack Webhook + +```json +{ + "text": "New article from __FEED__", + "attachments": [{ + "title": "__TITLE__", + "title_link": "__URL__", + "text": "__CONTENT__", + "color": "good" + }] +} +``` + +### 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 + +```text +/security.*/i +/\b(urgent|critical)\b/i +``` + +### Simple Strings + +```text +breaking news +security alert +``` + +## 🛠️ Advanced Configuration + +### Custom Headers + +Add authentication or custom headers: + +```text +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) \ No newline at end of file diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 00000000..fd58d34f --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,199 @@ + + + + +
+ + +
+ ⚙️ +
+ + +
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + + + + + +
+ attributeBool('search_in_title') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_feed') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_authors') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_content') ? 'checked="checked"' : '' ?> /> + +
+
+
+ +
+ +
+ attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> /> +
+
+ +
+
+ + + +
+ 🌐 +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
__TITLE__
__URL__
__CONTENT__
__DATE__
__DATE_TIMESTAMP__
__AUTHORS__
__THUMBNAIL_URL__
__FEED__
__TAGS__
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ +
+
+
+ +
+ getWebhookBodyType() === 'json' ? 'checked' : '' ?>> + + + getWebhookBodyType() === 'form' ? 'checked' : '' ?>> + + +
+ + +
+
+
+
+ +
+
+ + +
+
+ + + +
+
+ +
\ No newline at end of file diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 00000000..991776a1 --- /dev/null +++ b/xExtension-Webhook/extension.php @@ -0,0 +1,453 @@ +"; + + /** + * 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 + * @throws Minz_PermissionDeniedException + */ + 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" => Minz_Request::paramBoolean("mark_as_read"), + "ignore_updated" => Minz_Request::paramBoolean("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" => Minz_Request::paramBoolean("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 + * + * @throws FreshRSS_Context_Exception + * @throws Minz_PermissionDeniedException + * + * @return FreshRSS_Entry The processed entry (potentially marked as read) + */ + public function processArticle($entry): FreshRSS_Entry { + if (!is_object($entry)) { + return $entry; + } + + if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) { + logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + return $entry; + } + + $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 = 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 + 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 + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ + private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { + try { + $bodyStr = FreshRSS_Context::userConf()->attributeString('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( + FreshRSS_Context::userConf()->attributeString('webhook_url'), + FreshRSS_Context::userConf()->attributeString('webhook_method'), + FreshRSS_Context::userConf()->attributeString('webhook_body_type'), + $bodyStr, + FreshRSS_Context::userConf()->attributeArray('webhook_headers'), + FreshRSS_Context::userConf()->attributeBool('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. + * + * @throws FreshRSS_Context_Exception + * + * @return string Keywords separated by newlines + */ + public function getKeywordsData(): string { + $keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + return implode(PHP_EOL, $keywords); + } + + /** + * Get webhook headers configuration as formatted string + * + * 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 = FreshRSS_Context::userConf()->attributeArray('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. + * + * @throws FreshRSS_Context_Exception + * + * @return string The webhook URL + */ + public function getWebhookUrl(): string { + return FreshRSS_Context::userConf()->attributeString('webhook_url') ?? $this->webhook_url; + } + + /** + * Get configured webhook body template + * + * 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 = FreshRSS_Context::userConf()->attributeString('webhook_body'); + return ($body === null || $body === '') ? $this->webhook_body : $body; + } + + /** + * Get configured webhook body type + * + * 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 FreshRSS_Context::userConf()->attributeString('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 + * + * @throws Minz_PermissionDeniedException + * + * @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 + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function _LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +} \ No newline at end of file diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 00000000..cb9ea9d6 --- /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' + ), +); \ No newline at end of file diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json new file mode 100644 index 00000000..2f64212f --- /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" +} \ No newline at end of file diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php new file mode 100644 index 00000000..9f42951a --- /dev/null +++ b/xExtension-Webhook/request.php @@ -0,0 +1,309 @@ +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 + * @throws Minz_PermissionDeniedException + * + * @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 + * + * @throws Minz_PermissionDeniedException + * + * @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 + * @throws Minz_PermissionDeniedException + * + * @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) + * + * @throws Minz_PermissionDeniedException + * + * @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) + * + * @throws Minz_PermissionDeniedException + * + * @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 + * + * @throws Minz_PermissionDeniedException + * + * @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 + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +} \ No newline at end of file From 239dc6b0332941de4a7123b31eee8abad23ec7e4 Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Tue, 14 Apr 2026 20:50:15 +0000 Subject: [PATCH 02/15] Linting --- xExtension-Webhook/extension.php | 4 +--- xExtension-Webhook/i18n/en/ext.php | 2 +- xExtension-Webhook/request.php | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 991776a1..68101c2b 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -244,7 +244,6 @@ public function processArticle($entry): FreshRSS_Entry { if (!empty($additionalLog)) { $this->sendArticle($entry, $additionalLog); } - } catch (Throwable $err) { logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}"); } @@ -341,7 +340,6 @@ private function isPatternFound(string $pattern, string $text): bool { // 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; @@ -450,4 +448,4 @@ function _LOG(bool $logEnabled, $data): void { */ function _LOG_ERR(bool $logEnabled, $data): void { logError($logEnabled, $data); -} \ No newline at end of file +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index cb9ea9d6..ced0e41d 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -42,4 +42,4 @@ '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' ), -); \ No newline at end of file +); diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index 9f42951a..ab29ec89 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -71,12 +71,9 @@ function sendReq( // Execute request executeRequest($ch, $logEnabled); - } catch (Throwable $err) { logError($logEnabled, "Error in sendReq: {$err->getMessage()} | URL: {$url} | Body: {$body}"); throw $err; - } finally { - curl_close($ch); } } @@ -306,4 +303,4 @@ function LOG_WARN(bool $logEnabled, $data): void { */ function LOG_ERR(bool $logEnabled, $data): void { logError($logEnabled, $data); -} \ No newline at end of file +} From d445748cb783413d3087681c3645a247515ed2b6 Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Tue, 14 Apr 2026 21:14:48 +0000 Subject: [PATCH 03/15] Fixes for successful make test-all --- xExtension-Webhook/extension.php | 63 ++++++++++++++------------------ xExtension-Webhook/request.php | 24 ++++++------ 2 files changed, 40 insertions(+), 47 deletions(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 68101c2b..3ad82d83 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -112,12 +112,13 @@ public function init(): void { * @return void * @throws Minz_PermissionDeniedException */ + #[\Override] public function handleConfigureAction(): void { $this->registerTranslates(); if (Minz_Request::isPost()) { $conf = [ - "keywords" => array_filter(Minz_Request::paramTextToArray("keywords")), + "keywords" => array_filter(Minz_Request::paramTextToArray("keywords"), static fn(string $v): bool => $v !== ''), "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"), @@ -127,7 +128,7 @@ public function handleConfigureAction(): void { "webhook_url" => Minz_Request::paramString("webhook_url"), "webhook_method" => Minz_Request::paramString("webhook_method"), - "webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers")), + "webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers"), static fn(string $v): bool => $v !== ''), "webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")), "webhook_body_type" => Minz_Request::paramString("webhook_body_type"), "enable_logging" => Minz_Request::paramBoolean("enable_logging"), @@ -138,7 +139,7 @@ public function handleConfigureAction(): void { logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf)); - if (Minz_Request::paramString("test_request")) { + if (Minz_Request::paramString("test_request") !== '') { try { sendReq( $conf["webhook_url"], @@ -185,13 +186,13 @@ public function processArticle($entry): FreshRSS_Entry { $searchInAuthors = FreshRSS_Context::userConf()->attributeBool('search_in_authors') ?? false; $searchInContent = FreshRSS_Context::userConf()->attributeBool('search_in_content') ?? false; - $patterns = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + $patterns = array_values(array_filter(FreshRSS_Context::userConf()->attributeArray('keywords') ?? [], 'is_string')); $markAsRead = FreshRSS_Context::userConf()->attributeBool('mark_as_read') ?? false; $logsEnabled = FreshRSS_Context::userConf()->attributeBool('enable_logging') ?? false; $this->logsEnabled = $logsEnabled; // Validate patterns - if (!is_array($patterns) || empty($patterns)) { + if (empty($patterns)) { logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings."); return $entry; } @@ -266,12 +267,12 @@ public function processArticle($entry): FreshRSS_Entry { */ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { try { - $bodyStr = FreshRSS_Context::userConf()->attributeString('webhook_body'); + $bodyStr = FreshRSS_Context::userConf()->attributeString('webhook_body') ?? ''; // Replace placeholders with actual values $replacements = [ "__TITLE__" => $this->toSafeJsonStr($entry->title()), - "__FEED__" => $this->toSafeJsonStr($entry->feed()->name()), + "__FEED__" => $this->toSafeJsonStr($entry->feed()?->name() ?? ''), "__URL__" => $this->toSafeJsonStr($entry->link()), "__CONTENT__" => $this->toSafeJsonStr($entry->content()), "__DATE__" => $this->toSafeJsonStr($entry->date()), @@ -283,12 +284,12 @@ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): $bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr); sendReq( - FreshRSS_Context::userConf()->attributeString('webhook_url'), - FreshRSS_Context::userConf()->attributeString('webhook_method'), - FreshRSS_Context::userConf()->attributeString('webhook_body_type'), + FreshRSS_Context::userConf()->attributeString('webhook_url') ?? '', + FreshRSS_Context::userConf()->attributeString('webhook_method') ?? '', + FreshRSS_Context::userConf()->attributeString('webhook_body_type') ?? '', $bodyStr, - FreshRSS_Context::userConf()->attributeArray('webhook_headers'), - FreshRSS_Context::userConf()->attributeBool('enable_logging'), + array_values(array_filter(FreshRSS_Context::userConf()->attributeArray('webhook_headers') ?? [], 'is_string')), + FreshRSS_Context::userConf()->attributeBool('enable_logging') ?? false, $additionalLog, ); } catch (Throwable $err) { @@ -312,7 +313,7 @@ private function toSafeJsonStr(string|int $str): string { } // Remove quotes and decode HTML entities - return str_replace('"', '', html_entity_decode((string)$str)); + return str_replace('"', '', html_entity_decode($str)); } /** @@ -331,19 +332,14 @@ private function isPatternFound(string $pattern, string $text): bool { 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; + // 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); } /** @@ -357,7 +353,7 @@ private function isPatternFound(string $pattern, string $text): bool { * @return string Keywords separated by newlines */ public function getKeywordsData(): string { - $keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + $keywords = array_values(array_filter(FreshRSS_Context::userConf()->attributeArray('keywords') ?? [], 'is_string')); return implode(PHP_EOL, $keywords); } @@ -372,11 +368,8 @@ public function getKeywordsData(): string { * @return string HTTP headers separated by newlines */ public function getWebhookHeaders(): string { - $headers = FreshRSS_Context::userConf()->attributeArray('webhook_headers'); - return implode( - PHP_EOL, - is_array($headers) ? $headers : ($this->webhook_headers ?? []), - ); + $headers = array_values(array_filter(FreshRSS_Context::userConf()->attributeArray('webhook_headers') ?? $this->webhook_headers, 'is_string')); + return implode(PHP_EOL, $headers); } /** @@ -425,13 +418,13 @@ public function getWebhookBodyType(): string { * * @deprecated Use logWarning() instead * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log + * @param string $data Data to log * * @throws Minz_PermissionDeniedException * * @return void */ -function _LOG(bool $logEnabled, $data): void { +function _LOG(bool $logEnabled, string $data): void { logWarning($logEnabled, $data); } @@ -440,12 +433,12 @@ function _LOG(bool $logEnabled, $data): void { * * @deprecated Use logError() instead * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log + * @param string $data Data to log * * @throws Minz_PermissionDeniedException * * @return void */ -function _LOG_ERR(bool $logEnabled, $data): void { +function _LOG_ERR(bool $logEnabled, string $data): void { logError($logEnabled, $data); } diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index ab29ec89..aefda276 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -33,7 +33,7 @@ function sendReq( string $additionalLog = "", ): void { // Validate inputs - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { + if (empty($url) || filter_var($url, FILTER_VALIDATE_URL) === false) { throw new InvalidArgumentException("Invalid URL provided: {$url}"); } @@ -64,7 +64,7 @@ function sendReq( // Configure headers $finalHeaders = configureHeaders($headers, $bodyType); - curl_setopt($ch, CURLOPT_HTTPHEADER, $finalHeaders); + curl_setopt($ch, CURLOPT_HTTPHEADER, array_values($finalHeaders)); // Log the request logRequest($logEnabled, $additionalLog, $method, $url, $bodyType, $processedBody, $finalHeaders); @@ -136,7 +136,7 @@ function processHttpBody(string $body, string $bodyType, string $method, bool $l return match ($bodyType) { 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR), - 'form' => http_build_query($bodyObject ?? []), + 'form' => http_build_query(is_array($bodyObject) ? $bodyObject : []), default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}") }; } catch (JsonException $err) { @@ -200,7 +200,7 @@ function logRequest( } $cleanUrl = urldecode($url); - $cleanBody = $body ? str_replace('\/', '/', $body) : ''; + $cleanBody = ($body !== null) ? str_replace('\/', '/', $body) : ''; $headersJson = json_encode($headers); $logMessage = trim("{$additionalLog} ♦♦ sendReq ⏩ {$method}: {$cleanUrl} ♦♦ {$bodyType} ♦♦ {$cleanBody} ♦♦ {$headersJson}"); @@ -244,13 +244,13 @@ function executeRequest(CurlHandle $ch, bool $logEnabled): void { * with proper class existence checking. * * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log (will be converted to string) + * @param string $data Data to log (will be converted to string) * * @throws Minz_PermissionDeniedException * * @return void */ -function logWarning(bool $logEnabled, $data): void { +function logWarning(bool $logEnabled, string $data): void { if ($logEnabled && class_exists('Minz_Log')) { Minz_Log::warning("[WEBHOOK] " . $data); } @@ -263,13 +263,13 @@ function logWarning(bool $logEnabled, $data): void { * with proper class existence checking. * * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log (will be converted to string) + * @param string $data Data to log (will be converted to string) * * @throws Minz_PermissionDeniedException * * @return void */ -function logError(bool $logEnabled, $data): void { +function logError(bool $logEnabled, string $data): void { if ($logEnabled && class_exists('Minz_Log')) { Minz_Log::error("[WEBHOOK]❌ " . $data); } @@ -280,13 +280,13 @@ function logError(bool $logEnabled, $data): void { * * @deprecated Use logWarning() instead * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log + * @param string $data Data to log * * @throws Minz_PermissionDeniedException * * @return void */ -function LOG_WARN(bool $logEnabled, $data): void { +function LOG_WARN(bool $logEnabled, string $data): void { logWarning($logEnabled, $data); } @@ -295,12 +295,12 @@ function LOG_WARN(bool $logEnabled, $data): void { * * @deprecated Use logError() instead * @param bool $logEnabled Whether logging is enabled - * @param mixed $data Data to log + * @param string $data Data to log * * @throws Minz_PermissionDeniedException * * @return void */ -function LOG_ERR(bool $logEnabled, $data): void { +function LOG_ERR(bool $logEnabled, string $data): void { logError($logEnabled, $data); } From 5f7d69e81f600b320a9613eb271605d91fe72f80 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 19 Apr 2026 14:01:53 +0200 Subject: [PATCH 04/15] i18n --- xExtension-Webhook/README.MD | 4 +-- xExtension-Webhook/configure.phtml | 2 +- xExtension-Webhook/i18n/en/ext.php | 4 +-- xExtension-Webhook/i18n/fr/ext.php | 45 ++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 xExtension-Webhook/i18n/fr/ext.php diff --git a/xExtension-Webhook/README.MD b/xExtension-Webhook/README.MD index 1af23118..75abb2f8 100644 --- a/xExtension-Webhook/README.MD +++ b/xExtension-Webhook/README.MD @@ -79,7 +79,7 @@ Customize the webhook payload using placeholders: #### Available Placeholders | Placeholder | Description | -|-------------|-------------| +| ----------- | ----------- | | `__TITLE__` | Article title | | `__FEED__` | Feed name | | `__URL__` | Article URL | @@ -239,4 +239,4 @@ This extension is licensed under the [GNU Affero General Public License v3.0](LI - [FreshRSS Documentation](https://freshrss.github.io/FreshRSS/) - [GitHub Issues](https://github.com/FreshRSS/Extensions/issues) -- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions) \ No newline at end of file +- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index fd58d34f..824f0099 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -196,4 +196,4 @@ declare(strict_types=1); - \ No newline at end of file + diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index ced0e41d..d1e20fce 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -7,9 +7,9 @@ '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.', + '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' => 'Search in article’s:', 'search_in_title' => 'title', 'search_in_feed' => 'feed', 'search_in_content' => 'content', diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php new file mode 100644 index 00000000..d10553cc --- /dev/null +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -0,0 +1,45 @@ + array( + 'event_settings' => 'Paramètres des événements', + 'show_hide' => 'afficher/masquer', + 'webhook_settings' => 'Paramètres du webhook', + 'more_options' => 'Plus d’options (en-têtes, format,…) :', + 'save_and_send_test_req' => 'Enregistrer et envoyer une requête de test', + 'description' => 'Les webhooks permettent de notifier des services externes lorsque certains événements se produisent.\nLorsque les événements spécifiés se produisent, nous envoyons une requête HTTP (généralement POST) à l’URL que vous fournissez.', + 'keywords' => 'Mots-clés dans le nouvel article', + 'search_in' => 'Rechercher dans :', + 'search_in_title' => 'titre', + 'search_in_feed' => 'flux', + 'search_in_content' => 'contenu', + 'search_in_all' => 'tout', + 'search_in_none' => 'aucun', + 'keywords_description' => 'Chaque ligne est vérifiée individuellement. En plus des textes normaux, des expressions RegEx peuvent également être définies (elles sont évaluées à l’aide de la fonction PHP preg_match).', + 'search_in_title_label' => '🪧 titre *   ', + 'search_in_feed_label' => '💼 flux   ', + 'search_in_authors_label' => '👥 auteurs   ', + 'search_in_content_label' => '📄 contenu   ', + 'mark_as_read' => 'Marquer comme lu', + 'mark_as_read_description' => 'Marquer l’article comme lu après l’envoi du webhook.', + 'mark_as_read_label' => 'Marquer comme lu', + 'http_body' => 'Corps HTTP', + 'http_body_description' => 'Doit être du JSON valide ou des données de formulaire (x-www-form-urlencoded)', + 'http_body_placeholder_summary' => 'Vous pouvez utiliser des espaces réservés spéciaux qui seront remplacés par les valeurs réelles :', + 'http_body_placeholder_title' => 'Espace réservé', + 'http_body_placeholder_description' => 'Description', + 'http_body_placeholder_title_description' => 'Titre de l’article', + 'http_body_placeholder_url_description' => 'Lien de l’article encodé en HTML', + 'http_body_placeholder_content_description' => 'Contenu de l’article (format HTML)', + 'http_body_placeholder_authors_description' => 'Auteurs de l’article', + 'http_body_placeholder_feed_description' => 'Flux de l’article', + 'http_body_placeholder_tags_description' => 'Étiquettes de l’article (chaîne, séparées par « # »)', + 'http_body_placeholder_date_description' => 'Date de l’article (chaîne)', + 'http_body_placeholder_date_timestamp_description' => 'Date de l’article en horodatage (nombre)', + 'http_body_placeholder_thumbnail_url_description' => 'URL de la miniature (image)', + 'webhook_headers' => 'En-têtes HTTP
(un par ligne)', + 'http_body_type' => 'Type de corps HTTP', + 'more_info' => 'Plus d’informations :', + 'more_info_description' => 'Lorsque l’en-tête contient Content-type: application/x-www-form-urlencoded, les clés et les valeurs sont encodées en tuples clé-valeur séparés par « & », avec un « = » entre la clé et la valeur. Les caractères non alphanumériques dans les clés et les valeurs sont encodés en URL', + ), +); From 2963e882a092b2a2b65ffabdbf111aed7b797479 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 19 Apr 2026 14:04:28 +0200 Subject: [PATCH 05/15] Readme entry --- README.md | 1 + xExtension-Webhook/{README.MD => README.md} | 0 2 files changed, 1 insertion(+) rename xExtension-Webhook/{README.MD => README.md} (100%) diff --git a/README.md b/README.md index 65f623c0..ee3470c3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Those extensions are in the current official extension repository, with automati * [Show Feed ID](xExtension-showFeedID/README.md): Show the ID of feed and category * [Title-Wrap](xExtension-TitleWrap/README.md): Applies a line-wrap to long article titles instead of truncating them * [Unsafe Autologin](xExtension-UnsafeAutologin/README.md): Brings back removed unsafe autologin feature from FreshRSS +* [Webhook](xExtension-Webhook/README.md): Automatically sends webhook notifications when RSS entries match specified keywords * [Word Highlighter](xExtension-WordHighlighter/README.md): Highlight specific words * [YouTube Video Feed](xExtension-YouTube/README.md): Embed YouTube feeds inside article content diff --git a/xExtension-Webhook/README.MD b/xExtension-Webhook/README.md similarity index 100% rename from xExtension-Webhook/README.MD rename to xExtension-Webhook/README.md From 43aa0a1a8581fa324b25b4ae68f4b543ff740037 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 19 Apr 2026 14:12:19 +0200 Subject: [PATCH 06/15] Favour faster single quotes, and Heredoc --- xExtension-Webhook/extension.php | 118 ++++++++++++++++--------------- xExtension-Webhook/request.php | 6 +- 2 files changed, 63 insertions(+), 61 deletions(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 3ad82d83..ba37c58a 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -2,7 +2,7 @@ declare(strict_types=1); -include __DIR__ . "/request.php"; +include __DIR__ . '/request.php'; /** * Enumeration for HTTP request body types @@ -10,8 +10,8 @@ * Defines the supported content types for webhook request bodies. */ enum BODY_TYPE: string { - case JSON = "json"; - case FORM = "form"; + case JSON = 'json'; + case FORM = 'form'; } /** @@ -20,13 +20,13 @@ enum BODY_TYPE: string { * Defines the supported HTTP methods for webhook requests. */ enum HTTP_METHOD: string { - case GET = "GET"; - case POST = "POST"; - case PUT = "PUT"; - case DELETE = "DELETE"; - case PATCH = "PATCH"; - case OPTIONS = "OPTIONS"; - case HEAD = "HEAD"; + case GET = 'GET'; + case POST = 'POST'; + case PUT = 'PUT'; + case DELETE = 'DELETE'; + case PATCH = 'PATCH'; + case OPTIONS = 'OPTIONS'; + case HEAD = 'HEAD'; } /** @@ -67,14 +67,14 @@ class WebhookExtension extends Minz_Extension { * * @var string */ - public string $webhook_url = "http://"; + public string $webhook_url = 'http://'; /** * Default HTTP headers for webhook requests * * @var string[] */ - public array $webhook_headers = ["User-Agent: FreshRSS", "Content-Type: application/x-www-form-urlencoded"]; + public array $webhook_headers = ['User-Agent: FreshRSS', 'Content-Type: application/x-www-form-urlencoded']; /** * Default webhook request body template @@ -83,12 +83,14 @@ class WebhookExtension extends Minz_Extension { * * @var string */ - public string $webhook_body = '{ - "title": "__TITLE__", - "feed": "__FEED__", - "url": "__URL__", - "created": "__DATE_TIMESTAMP__" -}'; + public string $webhook_body = <<<'JSON' + { + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "created": "__DATE_TIMESTAMP__" + } + JSON; /** * Initialize the extension @@ -100,7 +102,7 @@ class WebhookExtension extends Minz_Extension { #[\Override] public function init(): void { $this->registerTranslates(); - $this->registerHook("entry_before_insert", [$this, "processArticle"]); + $this->registerHook('entry_before_insert', [$this, 'processArticle']); } /** @@ -118,37 +120,37 @@ public function handleConfigureAction(): void { if (Minz_Request::isPost()) { $conf = [ - "keywords" => array_filter(Minz_Request::paramTextToArray("keywords"), static fn(string $v): bool => $v !== ''), - "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" => Minz_Request::paramBoolean("mark_as_read"), - "ignore_updated" => Minz_Request::paramBoolean("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"), static fn(string $v): bool => $v !== ''), - "webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")), - "webhook_body_type" => Minz_Request::paramString("webhook_body_type"), - "enable_logging" => Minz_Request::paramBoolean("enable_logging"), + 'keywords' => array_filter(Minz_Request::paramTextToArray('keywords'), static fn(string $v): bool => $v !== ''), + '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' => Minz_Request::paramBoolean('mark_as_read'), + 'ignore_updated' => Minz_Request::paramBoolean('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'), static fn(string $v): bool => $v !== ''), + 'webhook_body' => html_entity_decode(Minz_Request::paramString('webhook_body')), + 'webhook_body_type' => Minz_Request::paramString('webhook_body_type'), + 'enable_logging' => Minz_Request::paramBoolean('enable_logging'), ]; $this->setSystemConfiguration($conf); - $logsEnabled = $conf["enable_logging"]; - $this->logsEnabled = $conf["enable_logging"]; + $logsEnabled = $conf['enable_logging']; + $this->logsEnabled = $conf['enable_logging']; - logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf)); + logWarning($logsEnabled, 'saved config: ✅ ' . json_encode($conf)); - if (Minz_Request::paramString("test_request") !== '') { + 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" + $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()}"); @@ -177,7 +179,7 @@ public function processArticle($entry): FreshRSS_Entry { } if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) { - logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + logWarning(true, '⚠️ ignore_updated: ' . $entry->link() . ' ♦♦ ' . $entry->title()); return $entry; } @@ -193,13 +195,13 @@ public function processArticle($entry): FreshRSS_Entry { // Validate patterns if (empty($patterns)) { - logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings."); + logError($logsEnabled, '❗️ No keywords defined in Webhook extension settings.'); return $entry; } - $title = "❗️NOT INITIALIZED"; - $link = "❗️NOT INITIALIZED"; - $additionalLog = ""; + $title = '❗️NOT INITIALIZED'; + $link = '❗️NOT INITIALIZED'; + $additionalLog = ''; try { $title = $entry->title(); @@ -265,20 +267,20 @@ public function processArticle($entry): FreshRSS_Entry { * * @return void */ - private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { + private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ''): void { try { $bodyStr = FreshRSS_Context::userConf()->attributeString('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)), + '__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); diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index aefda276..c718f950 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -49,7 +49,7 @@ function sendReq( $ch = curl_init($url); if ($ch === false) { - throw new RuntimeException("Failed to initialize cURL session"); + throw new RuntimeException('Failed to initialize cURL session'); } try { @@ -252,7 +252,7 @@ function executeRequest(CurlHandle $ch, bool $logEnabled): void { */ function logWarning(bool $logEnabled, string $data): void { if ($logEnabled && class_exists('Minz_Log')) { - Minz_Log::warning("[WEBHOOK] " . $data); + Minz_Log::warning('[WEBHOOK] ' . $data); } } @@ -271,7 +271,7 @@ function logWarning(bool $logEnabled, string $data): void { */ function logError(bool $logEnabled, string $data): void { if ($logEnabled && class_exists('Minz_Log')) { - Minz_Log::error("[WEBHOOK]❌ " . $data); + Minz_Log::error('[WEBHOOK]❌ ' . $data); } } From 932270a2fcd3f66a3c06898a242404f531e335fc Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 19 Apr 2026 14:29:04 +0200 Subject: [PATCH 07/15] Rework saving/reading configuration values --- xExtension-Webhook/README.md | 4 +- xExtension-Webhook/configure.phtml | 12 ++-- xExtension-Webhook/extension.php | 104 +++++++++++++---------------- xExtension-Webhook/metadata.json | 7 +- 4 files changed, 56 insertions(+), 71 deletions(-) diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index 75abb2f8..05b3e683 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -18,9 +18,7 @@ A powerful FreshRSS extension that automatically sends webhook notifications whe ## 📋 Requirements -- FreshRSS 1.20.0 or later -- PHP 8.1 or later -- cURL extension enabled +- FreshRSS 1.28.2+ ## 🔧 Installation diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index 824f0099..7e4a09e5 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -32,22 +32,22 @@ declare(strict_types=1); attributeBool('search_in_title') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('search_in_title') ? 'checked="checked"' : '' ?> /> attributeBool('search_in_feed') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('search_in_feed') ? 'checked="checked"' : '' ?> /> attributeBool('search_in_authors') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('search_in_authors') ? 'checked="checked"' : '' ?> /> attributeBool('search_in_content') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('search_in_content') ? 'checked="checked"' : '' ?> /> @@ -59,7 +59,7 @@ declare(strict_types=1);
attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('mark_as_read') ? 'checked="checked"' : '' ?> />
@@ -78,7 +78,7 @@ declare(strict_types=1);
@@ -14,44 +12,13 @@ declare(strict_types=1);
- +
- +
- - - -
-
- - -
- -
- - - - - - - -
- getUserConfigurationBool('search_in_title') ? 'checked="checked"' : '' ?> /> - - - getUserConfigurationBool('search_in_feed') ? 'checked="checked"' : '' ?> /> - - - getUserConfigurationBool('search_in_authors') ? 'checked="checked"' : '' ?> /> - - - getUserConfigurationBool('search_in_content') ? 'checked="checked"' : '' ?> /> - -
+
@@ -78,21 +45,21 @@ declare(strict_types=1);
- +
- +
@@ -161,19 +128,20 @@ declare(strict_types=1);
- +
getWebhookBodyType() === 'json' ? 'checked' : '' ?>> + getWebhookBodyType() === 'json' ? 'checked="checked"' : '' ?> /> getWebhookBodyType() === 'form' ? 'checked' : '' ?>> + getWebhookBodyType() === 'form' ? 'checked="checked"' : '' ?> />
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 9f5e8dd0..c602d759 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -33,8 +33,8 @@ enum HTTP_METHOD: string { * FreshRSS Webhook Extension * * This extension allows sending webhook notifications when RSS entries match - * specified keywords. It supports pattern matching in titles, feeds, authors, - * and content, with configurable HTTP methods and request formats. + * configured search filters. It supports FreshRSS native search filter syntax + * with configurable HTTP methods and request formats. * * @author Lukas Melega, Ryahn * @version 0.1.1 @@ -119,19 +119,15 @@ public function handleConfigureAction(): void { $this->registerTranslates(); if (Minz_Request::isPost()) { - $this->setUserConfigurationValue('keywords', array_filter(Minz_Request::paramTextToArray('keywords'), static fn(string $v): bool => $v !== '')); - $this->setUserConfigurationValue('search_in_title', Minz_Request::paramBoolean('search_in_title')); - $this->setUserConfigurationValue('search_in_feed', Minz_Request::paramBoolean('search_in_feed')); - $this->setUserConfigurationValue('search_in_authors', Minz_Request::paramBoolean('search_in_authors')); - $this->setUserConfigurationValue('search_in_content', Minz_Request::paramBoolean('search_in_content')); + $this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true))); $this->setUserConfigurationValue('mark_as_read', Minz_Request::paramBoolean('mark_as_read')); $this->setUserConfigurationValue('ignore_updated', Minz_Request::paramBoolean('ignore_updated')); - $this->setUserConfigurationValue('webhook_url', Minz_Request::paramString('webhook_url')); - $this->setUserConfigurationValue('webhook_method', Minz_Request::paramString('webhook_method')); + $this->setUserConfigurationValue('webhook_url', trim(Minz_Request::paramString('webhook_url', plaintext: true))); + $this->setUserConfigurationValue('webhook_method', trim(Minz_Request::paramString('webhook_method', plaintext: true))); $this->setUserConfigurationValue('webhook_headers', array_filter(Minz_Request::paramTextToArray('webhook_headers'), static fn(string $v): bool => $v !== '')); - $this->setUserConfigurationValue('webhook_body', html_entity_decode(Minz_Request::paramString('webhook_body'))); - $this->setUserConfigurationValue('webhook_body_type', Minz_Request::paramString('webhook_body_type')); + $this->setUserConfigurationValue('webhook_body', trim(Minz_Request::paramString('webhook_body', plaintext: true))); + $this->setUserConfigurationValue('webhook_body_type', trim(Minz_Request::paramString('webhook_body_type', plaintext: true))); $this->setUserConfigurationValue('enable_logging', Minz_Request::paramBoolean('enable_logging')); $logsEnabled = $this->getUserConfigurationBool('enable_logging') ?? false; @@ -139,7 +135,7 @@ public function handleConfigureAction(): void { logWarning($logsEnabled, 'saved config: ✅'); - if (Minz_Request::paramString('test_request') !== '') { + if (Minz_Request::paramString('test_request', plaintext: true) !== '') { try { sendReq( $this->getUserConfigurationString('webhook_url') ?? '', @@ -158,11 +154,10 @@ public function handleConfigureAction(): void { } /** - * Process article and send webhook if patterns match + * Process article and send webhook if search filter matches * - * Analyzes RSS entries against configured keyword patterns and sends - * webhook notifications for matching entries. Supports pattern matching - * in titles, feeds, authors, and content. + * Evaluates RSS entries against configured search filters and sends + * webhook notifications for matching entries. * * @param FreshRSS_Entry $entry The RSS entry to process * @@ -180,77 +175,60 @@ public function processArticle($entry): FreshRSS_Entry { return $entry; } - $searchInTitle = $this->getUserConfigurationBool('search_in_title') ?? false; - $searchInFeed = $this->getUserConfigurationBool('search_in_feed') ?? false; - $searchInAuthors = $this->getUserConfigurationBool('search_in_authors') ?? false; - $searchInContent = $this->getUserConfigurationBool('search_in_content') ?? false; - - $patterns = array_values(array_filter($this->getUserConfigurationArray('keywords') ?? [], 'is_string')); $markAsRead = $this->getUserConfigurationBool('mark_as_read') ?? false; $logsEnabled = $this->getUserConfigurationBool('enable_logging') ?? false; $this->logsEnabled = $logsEnabled; - // Validate patterns - if (empty($patterns)) { - logError($logsEnabled, '❗️ No keywords defined in Webhook extension settings.'); - return $entry; - } - - $title = '❗️ NOT INITIALIZED'; - $link = '❗️ NOT INITIALIZED'; - $additionalLog = ''; - try { + if (!$this->entryMatchesSearchFilter($entry)) { + return $entry; + } + $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; - } - } + $additionalLog = "✔️ matched entry: \"{$title}\" ❖ link: {$link}"; + logWarning($logsEnabled, $additionalLog); if ($markAsRead) { - $entry->_isRead($markAsRead); + $entry->_isRead(true); } - // Only send webhook if a pattern was matched - if (!empty($additionalLog)) { - $this->sendArticle($entry, $additionalLog); - } + $this->sendArticle($entry, $additionalLog); } catch (Throwable $err) { - logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}"); + logError($logsEnabled, "Error during processing article: {$err->getMessage()}"); } return $entry; } + /** + * Check if entry matches the configured search filter + * + * Evaluates the entry against each line of the search filter. + * Lines act as OR conditions — the first match returns true. + * An empty filter matches all entries. + * + * @param FreshRSS_Entry $entry The RSS entry to check + * + * @return bool True if the entry matches any filter line, or if no filter is configured + */ + private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool { + $searchFilter = $this->getUserConfigurationString('search_filter') ?? ''; + if ($searchFilter === '') { + return true; + } + + $lines = array_filter(array_map('trim', explode("\n", $searchFilter)), static fn(string $line): bool => $line !== ''); + foreach ($lines as $line) { + $booleanSearch = new FreshRSS_BooleanSearch($line); + if ($entry->matches($booleanSearch)) { + return true; + } + } + + return false; + } + /** * Send article data via webhook * @@ -316,42 +294,14 @@ private function toSafeJsonStr(string|int $str): string { } /** - * 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 + * Get configured search filter * - * @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 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); - } - - /** - * Get keywords configuration as formatted string - * - * Returns the configured keywords as a newline-separated string - * for display in the configuration form. + * Returns the configured search filter string for display in the configuration form. * - * @return string Keywords separated by newlines + * @return string The search filter string */ - public function getKeywordsData(): string { - $keywords = array_values(array_filter($this->getUserConfigurationArray('keywords') ?? [], 'is_string')); - return implode(PHP_EOL, $keywords); + public function getSearchFilter(): string { + return $this->getUserConfigurationString('search_filter') ?? ''; } /** diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index d1e20fce..098cf14a 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -8,18 +8,8 @@ '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   ', + 'search_filter' => 'Search filter', + 'search_filter_description' => 'Uses FreshRSS search filter syntax. Each line is an OR condition. Leave empty to match all entries.', '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', diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php index d10553cc..331ffc9c 100644 --- a/xExtension-Webhook/i18n/fr/ext.php +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -8,18 +8,8 @@ 'more_options' => 'Plus d’options (en-têtes, format,…) :', 'save_and_send_test_req' => 'Enregistrer et envoyer une requête de test', 'description' => 'Les webhooks permettent de notifier des services externes lorsque certains événements se produisent.\nLorsque les événements spécifiés se produisent, nous envoyons une requête HTTP (généralement POST) à l’URL que vous fournissez.', - 'keywords' => 'Mots-clés dans le nouvel article', - 'search_in' => 'Rechercher dans :', - 'search_in_title' => 'titre', - 'search_in_feed' => 'flux', - 'search_in_content' => 'contenu', - 'search_in_all' => 'tout', - 'search_in_none' => 'aucun', - 'keywords_description' => 'Chaque ligne est vérifiée individuellement. En plus des textes normaux, des expressions RegEx peuvent également être définies (elles sont évaluées à l’aide de la fonction PHP preg_match).', - 'search_in_title_label' => '🪧 titre *   ', - 'search_in_feed_label' => '💼 flux   ', - 'search_in_authors_label' => '👥 auteurs   ', - 'search_in_content_label' => '📄 contenu   ', + 'search_filter' => 'Filtre de recherche', + 'search_filter_description' => 'Utilise la syntaxe de filtre de recherche FreshRSS. Chaque ligne est une condition OU. Laisser vide pour correspondre à tous les articles.', 'mark_as_read' => 'Marquer comme lu', 'mark_as_read_description' => 'Marquer l’article comme lu après l’envoi du webhook.', 'mark_as_read_label' => 'Marquer comme lu', From 43a43c8e33c24d9e40dee1b4a5940858f0b3ce47 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 19 Apr 2026 17:59:10 +0200 Subject: [PATCH 12/15] Use same placeholder conventions than LLmClassification --- xExtension-Webhook/README.md | 60 +++++++++++++++--------------- xExtension-Webhook/configure.phtml | 22 ++++++----- xExtension-Webhook/extension.php | 29 ++++++++------- xExtension-Webhook/i18n/en/ext.php | 3 +- xExtension-Webhook/i18n/fr/ext.php | 3 +- 5 files changed, 63 insertions(+), 54 deletions(-) diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index bbf3b26e..aeb35a56 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -56,14 +56,14 @@ Customize the webhook payload using placeholders: ```json { - "title": "__TITLE__", - "feed": "__FEED__", - "url": "__URL__", - "content": "__CONTENT__", - "date": "__DATE__", - "timestamp": "__DATE_TIMESTAMP__", - "authors": "__AUTHORS__", - "tags": "__TAGS__" + "title": "{title}", + "feed": "{feed_name}", + "url": "{url}", + "content": "{content}", + "date": "{date}", + "timestamp": "{date_timestamp}", + "author": "{author}", + "tags": "{tags}" } ``` @@ -71,14 +71,16 @@ Customize the webhook payload using placeholders: | 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 | +| `{title}` | Article title | +| `{url}` | Article URL | +| `{content}` | Article content (HTML) | +| `{date}` | Publication date (string) | +| `{date_timestamp}` | Publication date as Unix timestamp | +| `{author}` | Article authors | +| `{feed_name}` | Feed name | +| `{feed_url}` | Feed URL | +| `{thumbnail_url}` | Thumbnail (image) URL | +| `{tags}` | Article tags (separated by " #") | ## 🎯 Use Cases @@ -86,14 +88,14 @@ Customize the webhook payload using placeholders: ```json { - "content": "New article: **__TITLE__**", + "content": "New article: **{title}**", "embeds": [{ - "title": "__TITLE__", - "url": "__URL__", - "description": "__CONTENT__", + "title": "{title}", + "url": "{url}", + "description": "{content}", "color": 3447003, "footer": { - "text": "__FEED__" + "text": "{feed_name}" } }] } @@ -103,11 +105,11 @@ Customize the webhook payload using placeholders: ```json { - "text": "New article from __FEED__", + "text": "New article from {feed_name}", "attachments": [{ - "title": "__TITLE__", - "title_link": "__URL__", - "text": "__CONTENT__", + "title": "{title}", + "title_link": "{url}", + "text": "{content}", "color": "good" }] } @@ -119,10 +121,10 @@ Customize the webhook payload using placeholders: { "event": "new_article", "data": { - "title": "__TITLE__", - "url": "__URL__", - "feed": "__FEED__", - "timestamp": "__DATE_TIMESTAMP__" + "title": "{title}", + "url": "{url}", + "feed": "{feed_name}", + "timestamp": "{date_timestamp}" } } ``` diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index a1cfaa26..86dc88e0 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -76,39 +76,43 @@ declare(strict_types=1); - __TITLE__ + {title} - __URL__ + {url} - __CONTENT__ + {content} - __DATE__ + {date} - __DATE_TIMESTAMP__ + {date_timestamp} - __AUTHORS__ + {author} - __THUMBNAIL_URL__ + {thumbnail_url} - __FEED__ + {feed_name} - __TAGS__ + {feed_url} + + + + {tags} diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index c602d759..e2cf6ba8 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -79,16 +79,16 @@ class WebhookExtension extends Minz_Extension { /** * Default webhook request body template * - * Supports placeholders like __TITLE__, __FEED__, __URL__, etc. + * Supports placeholders like {title}, {url}, {feed_name}, etc. * * @var string */ public string $webhook_body = <<<'JSON' { - "title": "__TITLE__", - "feed": "__FEED__", - "url": "__URL__", - "created": "__DATE_TIMESTAMP__" + "title": "{title}", + "feed": "{feed_name}", + "url": "{url}", + "created": "{date_timestamp}" } JSON; @@ -248,17 +248,18 @@ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ''): // 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)), + '{title}' => $this->toSafeJsonStr($entry->title()), + '{feed_name}' => $this->toSafeJsonStr($entry->feed()?->name() ?? ''), + '{feed_url}' => $this->toSafeJsonStr($entry->feed()?->url() ?? ''), + '{url}' => $this->toSafeJsonStr($entry->link()), + '{content}' => $this->toSafeJsonStr($entry->content()), + '{date}' => $this->toSafeJsonStr($entry->date()), + '{date_timestamp}' => $this->toSafeJsonStr($entry->date(true)), + '{author}' => $this->toSafeJsonStr($entry->authors(true)), + '{tags}' => $this->toSafeJsonStr($entry->tags(true)), ]; - $bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr); + $bodyStr = strtr($bodyStr, $replacements); sendReq( $this->getUserConfigurationString('webhook_url') ?? '', diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index 098cf14a..a60458db 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -22,7 +22,8 @@ '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_feed_description' => 'Name of the feed', + 'http_body_placeholder_feed_url_description' => 'URL of the feed', '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)', diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php index 331ffc9c..7e5b587d 100644 --- a/xExtension-Webhook/i18n/fr/ext.php +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -22,7 +22,8 @@ 'http_body_placeholder_url_description' => 'Lien de l’article encodé en HTML', 'http_body_placeholder_content_description' => 'Contenu de l’article (format HTML)', 'http_body_placeholder_authors_description' => 'Auteurs de l’article', - 'http_body_placeholder_feed_description' => 'Flux de l’article', + 'http_body_placeholder_feed_description' => 'Nom du flux', + 'http_body_placeholder_feed_url_description' => 'URL du flux', 'http_body_placeholder_tags_description' => 'Étiquettes de l’article (chaîne, séparées par « # »)', 'http_body_placeholder_date_description' => 'Date de l’article (chaîne)', 'http_body_placeholder_date_timestamp_description' => 'Date de l’article en horodatage (nombre)', From d18cf8e5c9dc4a722760c747027dab8193380fc3 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sat, 25 Apr 2026 12:26:56 +0200 Subject: [PATCH 13/15] Remove conflicting License file --- xExtension-Webhook/LICENSE | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 xExtension-Webhook/LICENSE diff --git a/xExtension-Webhook/LICENSE b/xExtension-Webhook/LICENSE deleted file mode 100644 index 6802bc4b..00000000 --- a/xExtension-Webhook/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -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. \ No newline at end of file From c97b6615b3d53948b9500cbf74dd71b94a9ce4a1 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 26 Apr 2026 13:03:44 +0200 Subject: [PATCH 14/15] Simplifications, reduce code Co-authored-by: Copilot --- xExtension-Webhook/README.md | 95 ++++----- xExtension-Webhook/configure.phtml | 50 ++--- xExtension-Webhook/extension.php | 299 ++++++++++++----------------- xExtension-Webhook/i18n/en/ext.php | 7 +- xExtension-Webhook/i18n/fr/ext.php | 7 +- xExtension-Webhook/request.php | 226 ---------------------- 6 files changed, 195 insertions(+), 489 deletions(-) delete mode 100644 xExtension-Webhook/request.php diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index aeb35a56..53bc7ed2 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -9,10 +9,9 @@ A powerful FreshRSS extension that automatically sends webhook notifications whe - **Automated Notifications**: Automatically sends webhooks when new RSS entries match your search filters - **Search Filters**: FreshRSS native search filter syntax for precise matching -- **Multiple HTTP Methods**: Supports GET, POST, PUT, DELETE, PATCH, OPTIONS, and HEAD +- **Multiple HTTP Methods**: Supports GET, POST, PUT - **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 @@ -56,14 +55,14 @@ Customize the webhook payload using placeholders: ```json { - "title": "{title}", - "feed": "{feed_name}", - "url": "{url}", - "content": "{content}", - "date": "{date}", - "timestamp": "{date_timestamp}", - "author": "{author}", - "tags": "{tags}" + "title": "{title}", + "feed": "{feed_name}", + "url": "{url}", + "content": "{content}", + "date": "{date}", + "timestamp": "{date_timestamp}", + "author": "{author}", + "tags": "{tags}" } ``` @@ -88,16 +87,16 @@ Customize the webhook payload using placeholders: ```json { - "content": "New article: **{title}**", - "embeds": [{ - "title": "{title}", - "url": "{url}", - "description": "{content}", - "color": 3447003, - "footer": { - "text": "{feed_name}" - } - }] + "content": "New article: **{title}**", + "embeds": [{ + "title": "{title}", + "url": "{url}", + "description": "{content}", + "color": 3447003, + "footer": { + "text": "{feed_name}" + } + }] } ``` @@ -105,13 +104,13 @@ Customize the webhook payload using placeholders: ```json { - "text": "New article from {feed_name}", - "attachments": [{ - "title": "{title}", - "title_link": "{url}", - "text": "{content}", - "color": "good" - }] + "text": "New article from {feed_name}", + "attachments": [{ + "title": "{title}", + "title_link": "{url}", + "text": "{content}", + "color": "good" + }] } ``` @@ -119,38 +118,26 @@ Customize the webhook payload using placeholders: ```json { - "event": "new_article", - "data": { - "title": "{title}", - "url": "{url}", - "feed": "{feed_name}", - "timestamp": "{date_timestamp}" - } + "event": "new_article", + "data": { + "title": "{title}", + "url": "{url}", + "feed": "{feed_name}", + "timestamp": "{date_timestamp}" + } } ``` ## 🔍 Search Filters -The extension uses [FreshRSS search filter syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) to match entries: - -### Filter by Field +The extension uses [FreshRSS search filter syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) to match entries. +Examples: ```text intitle:security inurl:example.com author:John -``` - -### Tags and Feeds - -```text #breaking-news -f:TechCrunch -``` - -### Boolean Logic - -```text intitle:urgent OR intitle:critical intitle:release -intitle:beta ``` @@ -186,12 +173,11 @@ User-Agent: FreshRSS-Webhook/1.0 **Webhooks not sending:** - Check that a search filter is configured (or leave empty to match all) - Verify webhook URL is accessible -- Enable logging to see detailed information +- Check the FreshRSS logs for `[Webhook]` entries **Filter not matching:** - Try simple filters first (e.g., `intitle:test`) - Refer to the [FreshRSS filter documentation](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) for syntax -- Enable logging to see which entries are evaluated **Authentication errors:** - Check custom headers configuration @@ -199,10 +185,9 @@ User-Agent: FreshRSS-Webhook/1.0 ### Debugging -Enable logging in the extension settings to see detailed information about: -- Filter matching results -- HTTP request details -- Response codes and errors +The extension logs to the FreshRSS log file with the `[Webhook]` prefix: +- **Debug level**: filter matches, HTTP requests sent (visible in `DEVELOPMENT` environment) +- **Warning level**: errors during article processing or failed requests (always visible) ## 📝 Changelog @@ -212,7 +197,7 @@ Enable logging in the extension settings to see detailed information about: - Automated webhook notifications - Pattern matching in multiple fields - Configurable HTTP methods and formats -- Comprehensive error handling and logging +- Comprehensive error handling - Template-based webhook payloads ## 🤝 Contributing diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index 86dc88e0..2b94bba5 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -15,7 +15,7 @@ declare(strict_types=1);

@@ -25,16 +25,14 @@ declare(strict_types=1);
- getUserConfigurationBool('mark_as_read') ? 'checked="checked"' : '' ?> /> + getUserConfigurationBool('mark_as_read') ? 'checked="checked"' : '' ?> />
- -
🌐
@@ -45,21 +43,25 @@ declare(strict_types=1);
- +
- +
@@ -88,20 +90,24 @@ declare(strict_types=1); - {date} - + {date_published} + - {date_timestamp} - + {date_received} + - {author} - + {date_modified} + + + + {date_user_modified} + - {thumbnail_url} - + {author} + {feed_name} @@ -124,8 +130,6 @@ declare(strict_types=1);
- -
@@ -141,11 +145,11 @@ declare(strict_types=1);
getWebhookBodyType() === 'json' ? 'checked="checked"' : '' ?> /> + getUserConfigurationString('webhook_body_type') === 'json' ? 'checked="checked"' : '' ?> /> getWebhookBodyType() === 'form' ? 'checked="checked"' : '' ?> /> + getUserConfigurationString('webhook_body_type') === 'form' ? 'checked="checked"' : '' ?> />
@@ -159,7 +163,6 @@ declare(strict_types=1);
-
@@ -167,5 +170,4 @@ declare(strict_types=1);
- diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index e2cf6ba8..e961032d 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -2,33 +2,6 @@ declare(strict_types=1); -include __DIR__ . '/request.php'; - -/** - * Enumeration for HTTP request body types - * - * Defines the supported content types for webhook request bodies. - */ -enum BODY_TYPE: string { - case JSON = 'json'; - case FORM = 'form'; -} - -/** - * Enumeration for HTTP methods - * - * Defines the supported HTTP methods for webhook requests. - */ -enum HTTP_METHOD: string { - case GET = 'GET'; - case POST = 'POST'; - case PUT = 'PUT'; - case DELETE = 'DELETE'; - case PATCH = 'PATCH'; - case OPTIONS = 'OPTIONS'; - case HEAD = 'HEAD'; -} - /** * FreshRSS Webhook Extension * @@ -37,60 +10,45 @@ enum HTTP_METHOD: string { * with configurable HTTP methods and request formats. * * @author Lukas Melega, Ryahn - * @version 0.1.1 - * @since FreshRSS 1.20.0 */ class WebhookExtension extends Minz_Extension { - /** - * Whether logging is enabled for this extension - * - * @var bool - */ - public bool $logsEnabled = false; - /** * Default HTTP method for webhook requests - * - * @var HTTP_METHOD */ - public HTTP_METHOD $webhook_method = HTTP_METHOD::POST; + public string $webhook_method = 'POST'; /** * Default body type for webhook requests - * - * @var BODY_TYPE */ - public BODY_TYPE $webhook_body_type = BODY_TYPE::JSON; + public string $webhook_body_type = 'json'; /** * Default webhook URL * * @var string */ - public string $webhook_url = 'http://'; + public string $webhook_url = 'https://example.net/webhook'; /** * Default HTTP headers for webhook requests * - * @var string[] + * @var list */ - public array $webhook_headers = ['User-Agent: FreshRSS', 'Content-Type: application/x-www-form-urlencoded']; + public array $webhook_headers = ['Content-Type: application/x-www-form-urlencoded']; /** * Default webhook request body template * * Supports placeholders like {title}, {url}, {feed_name}, etc. * - * @var string + * @var array */ - public string $webhook_body = <<<'JSON' - { - "title": "{title}", - "feed": "{feed_name}", - "url": "{url}", - "created": "{date_timestamp}" - } - JSON; + public array $webhook_body = [ + 'title' => '{title}', + 'feed' => '{feed_name}', + 'url' => '{url}', + 'created' => '{date_published}', + ]; /** * Initialize the extension @@ -126,28 +84,22 @@ public function handleConfigureAction(): void { $this->setUserConfigurationValue('webhook_method', trim(Minz_Request::paramString('webhook_method', plaintext: true))); $this->setUserConfigurationValue('webhook_headers', array_filter(Minz_Request::paramTextToArray('webhook_headers'), static fn(string $v): bool => $v !== '')); - $this->setUserConfigurationValue('webhook_body', trim(Minz_Request::paramString('webhook_body', plaintext: true))); + $webhookBodyJson = trim(Minz_Request::paramString('webhook_body', plaintext: true)); + $webhookBodyArray = $webhookBodyJson === '' ? [] : json_decode($webhookBodyJson, true, 256, JSON_INVALID_UTF8_SUBSTITUTE); + $this->setUserConfigurationValue('webhook_body', is_array($webhookBodyArray) ? $webhookBodyArray : []); $this->setUserConfigurationValue('webhook_body_type', trim(Minz_Request::paramString('webhook_body_type', plaintext: true))); - $this->setUserConfigurationValue('enable_logging', Minz_Request::paramBoolean('enable_logging')); - - $logsEnabled = $this->getUserConfigurationBool('enable_logging') ?? false; - $this->logsEnabled = $logsEnabled; - - logWarning($logsEnabled, 'saved config: ✅'); if (Minz_Request::paramString('test_request', plaintext: true) !== '') { try { - sendReq( + $this->sendRequest( $this->getUserConfigurationString('webhook_url') ?? '', $this->getUserConfigurationString('webhook_method') ?? '', $this->getUserConfigurationString('webhook_body_type') ?? '', - $this->getUserConfigurationString('webhook_body') ?? '', + $this->getUserConfigurationArray('webhook_body') ?? [], array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), - $logsEnabled, - 'Test request from configuration' ); } catch (Throwable $err) { - logError($logsEnabled, "Test request failed: {$err->getMessage()}"); + Minz_Log::warning('[Webhook] Test request failed: ' . $err->getMessage()); } } } @@ -160,42 +112,27 @@ public function handleConfigureAction(): void { * webhook notifications for matching entries. * * @param FreshRSS_Entry $entry The RSS entry to process - * * @throws Minz_PermissionDeniedException - * * @return FreshRSS_Entry The processed entry (potentially marked as read) */ - public function processArticle($entry): FreshRSS_Entry { - if (!is_object($entry)) { - return $entry; - } - + public function processArticle(FreshRSS_Entry $entry): FreshRSS_Entry { if ($this->getUserConfigurationBool('ignore_updated') && $entry->isUpdated()) { - logWarning(true, '⚠️ ignore_updated: ' . $entry->link() . ' ♦♦ ' . $entry->title()); return $entry; } $markAsRead = $this->getUserConfigurationBool('mark_as_read') ?? false; - $logsEnabled = $this->getUserConfigurationBool('enable_logging') ?? false; - $this->logsEnabled = $logsEnabled; try { if (!$this->entryMatchesSearchFilter($entry)) { return $entry; } - - $title = $entry->title(); - $link = $entry->link(); - $additionalLog = "✔️ matched entry: \"{$title}\" ❖ link: {$link}"; - logWarning($logsEnabled, $additionalLog); - if ($markAsRead) { $entry->_isRead(true); } - $this->sendArticle($entry, $additionalLog); + $this->sendArticle($entry); } catch (Throwable $err) { - logError($logsEnabled, "Error during processing article: {$err->getMessage()}"); + Minz_Log::warning('[Webhook] Error processing article: ' . $err->getMessage()); } return $entry; @@ -209,7 +146,6 @@ public function processArticle($entry): FreshRSS_Entry { * An empty filter matches all entries. * * @param FreshRSS_Entry $entry The RSS entry to check - * * @return bool True if the entry matches any filter line, or if no filter is configured */ private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool { @@ -230,79 +166,120 @@ private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool { } /** - * Send article data via webhook - * - * Prepares and sends webhook notification with article data. - * Replaces template placeholders with actual entry values. + * Recursively replace placeholders in an array structure * - * @param FreshRSS_Entry $entry The RSS entry to send - * @param string $additionalLog Additional context for logging - * - * @throws Minz_PermissionDeniedException + * Walks the array and applies placeholder replacement to string leaf values. + * If a replacement value is null and the placeholder is the entire string value, + * the value becomes null (preserving null semantics in JSON output). * - * @return void + * @param array $data The array to process + * @param array $replacements Placeholder => replacement value map + * @return array The array with placeholders replaced */ - private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ''): void { - try { - $bodyStr = $this->getUserConfigurationString('webhook_body') ?? ''; - - // Replace placeholders with actual values - $replacements = [ - '{title}' => $this->toSafeJsonStr($entry->title()), - '{feed_name}' => $this->toSafeJsonStr($entry->feed()?->name() ?? ''), - '{feed_url}' => $this->toSafeJsonStr($entry->feed()?->url() ?? ''), - '{url}' => $this->toSafeJsonStr($entry->link()), - '{content}' => $this->toSafeJsonStr($entry->content()), - '{date}' => $this->toSafeJsonStr($entry->date()), - '{date_timestamp}' => $this->toSafeJsonStr($entry->date(true)), - '{author}' => $this->toSafeJsonStr($entry->authors(true)), - '{tags}' => $this->toSafeJsonStr($entry->tags(true)), - ]; - - $bodyStr = strtr($bodyStr, $replacements); - - sendReq( - $this->getUserConfigurationString('webhook_url') ?? '', - $this->getUserConfigurationString('webhook_method') ?? '', - $this->getUserConfigurationString('webhook_body_type') ?? '', - $bodyStr, - array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), - $this->getUserConfigurationBool('enable_logging') ?? false, - $additionalLog, - ); - } catch (Throwable $err) { - logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}"); + private function replacePlaceholdersRecursive(array $data, array $replacements): array { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->replacePlaceholdersRecursive($value, $replacements); + } elseif (is_string($value)) { + // If the entire value is a single placeholder that maps to null, keep null + if (isset($replacements[$value]) || (array_key_exists($value, $replacements) && $replacements[$value] === null)) { + $data[$key] = $replacements[$value]; + } else { + $data[$key] = strtr($value, array_filter($replacements, static fn($v): bool => $v !== null)); + } + } } + return $data; } /** - * Convert string/int to safe JSON string - * - * Sanitizes input values for safe inclusion in JSON payloads - * by removing quotes and decoding HTML entities. + * Send article data via webhook * - * @param string|int $str Input value to sanitize + * Prepares and sends webhook notification with article data. + * Replaces template placeholders with actual entry values. * - * @return string Sanitized string safe for JSON inclusion + * @param FreshRSS_Entry $entry The RSS entry to send + * @throws \RuntimeException */ - 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($str)); + private function sendArticle(FreshRSS_Entry $entry): void { + $body = $this->getUserConfigurationArray('webhook_body') ?? $this->webhook_body; + + // Replace placeholders with actual values + $replacements = [ + '{title}' => htmlspecialchars_decode($entry->title(), ENT_QUOTES), + '{feed_name}' => htmlspecialchars_decode($entry->feed()?->name() ?? '', ENT_QUOTES), + '{feed_url}' => htmlspecialchars_decode($entry->feed()?->url() ?? '', ENT_QUOTES), + '{url}' => htmlspecialchars_decode($entry->link(), ENT_QUOTES), + '{content}' => htmlspecialchars_decode($entry->content(), ENT_QUOTES), + '{date_published}' => timestampToMachineDate($entry->date(raw: true)), + '{date_received}' => timestampToMachineDate($entry->dateAdded(raw: true)), + '{date_modified}' => $entry->lastModified() === null ? null : timestampToMachineDate($entry->lastModified()), + '{date_user_modified}' => $entry->lastUserModified() === null ? null : timestampToMachineDate($entry->lastUserModified()), + '{author}' => htmlspecialchars_decode($entry->authors(true), ENT_QUOTES), + '{tags}' => htmlspecialchars_decode($entry->tags(true), ENT_QUOTES), + ]; + + $body = $this->replacePlaceholdersRecursive($body, $replacements); + + $this->sendRequest( + $this->getUserConfigurationString('webhook_url') ?? '', + $this->getUserConfigurationString('webhook_method') ?? '', + $this->getUserConfigurationString('webhook_body_type') ?? '', + $body, + array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), + ); } /** - * Get configured search filter - * - * Returns the configured search filter string for display in the configuration form. - * - * @return string The search filter string + * Send an HTTP request via the FreshRSS HTTP utility + * + * @param string $url Target URL + * @param string $method HTTP method (GET, POST, PUT, etc.) + * @param string $bodyType Content type ('json' or 'form') + * @param array $body Request body as an array + * @param list $headers HTTP headers + * @throws \RuntimeException */ - public function getSearchFilter(): string { - return $this->getUserConfigurationString('search_filter') ?? ''; + private function sendRequest(string $url, string $method, string $bodyType, array $body, array $headers = []): void { + if ($url === '') { + throw new RuntimeException('Webhook URL is empty'); + } + + $processedBody = null; + if ($body !== [] && $method !== 'GET') { + $processedBody = match ($bodyType) { + 'form' => http_build_query($body), + default => json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + }; + } + + if (empty($headers)) { + $headers = match ($bodyType) { + 'form' => ['Content-Type: application/x-www-form-urlencoded'], + default => ['Content-Type: application/json'], + }; + } + + $curlOptions = [ + CURLOPT_HTTPHEADER => array_values($headers), + CURLOPT_TIMEOUT => 10, + ]; + + if ($method === 'POST') { + $curlOptions[CURLOPT_POST] = true; + } elseif ($method !== 'GET') { + $curlOptions[CURLOPT_CUSTOMREQUEST] = $method; + } + + if ($processedBody !== null && $method !== 'GET') { + $curlOptions[CURLOPT_POSTFIELDS] = $processedBody; + } + + $response = FreshRSS_http_Util::httpGet($url, cachePath: null, type: 'json', curl_options: $curlOptions); + + if ($response['fail'] ?? false) { + throw new RuntimeException('HTTP request failed for URL: ' . $url); + } } /** @@ -317,38 +294,4 @@ public function getWebhookHeaders(): string { $headers = array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? $this->webhook_headers, 'is_string')); return implode(PHP_EOL, $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 $this->getUserConfigurationString('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->getUserConfigurationString('webhook_body'); - return ($body === null || $body === '') ? $this->webhook_body : $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 $this->getUserConfigurationString('webhook_body_type') ?? $this->webhook_body_type->value; - } } diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index a60458db..912a577f 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -25,9 +25,10 @@ 'http_body_placeholder_feed_description' => 'Name of the feed', 'http_body_placeholder_feed_url_description' => 'URL of the feed', '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', + 'http_body_placeholder_date_published_description' => 'Publication date (ISO 8601)', + 'http_body_placeholder_date_received_description' => 'Date received by FreshRSS (ISO 8601)', + 'http_body_placeholder_date_modified_description' => 'Last modified date (ISO 8601, null if never modified)', + 'http_body_placeholder_date_user_modified_description' => 'Last user-modified date (ISO 8601, null if never modified)', 'webhook_headers' => 'HTTP Headers
(one per line)', 'http_body_type' => 'HTTP Body type', 'more_info' => 'More info:', diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php index 7e5b587d..72b6bc36 100644 --- a/xExtension-Webhook/i18n/fr/ext.php +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -25,9 +25,10 @@ 'http_body_placeholder_feed_description' => 'Nom du flux', 'http_body_placeholder_feed_url_description' => 'URL du flux', 'http_body_placeholder_tags_description' => 'Étiquettes de l’article (chaîne, séparées par « # »)', - 'http_body_placeholder_date_description' => 'Date de l’article (chaîne)', - 'http_body_placeholder_date_timestamp_description' => 'Date de l’article en horodatage (nombre)', - 'http_body_placeholder_thumbnail_url_description' => 'URL de la miniature (image)', + 'http_body_placeholder_date_published_description' => 'Date de publication (ISO 8601)', + 'http_body_placeholder_date_received_description' => 'Date de réception par FreshRSS (ISO 8601)', + 'http_body_placeholder_date_modified_description' => 'Date de dernière modification (ISO 8601, null si jamais modifié)', + 'http_body_placeholder_date_user_modified_description' => 'Date de dernière modification par l’utilisateur (ISO 8601, null si jamais modifié)', 'webhook_headers' => 'En-têtes HTTP
(un par ligne)', 'http_body_type' => 'Type de corps HTTP', 'more_info' => 'Plus d’informations :', diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php deleted file mode 100644 index 8eaf4c59..00000000 --- a/xExtension-Webhook/request.php +++ /dev/null @@ -1,226 +0,0 @@ - array_values($finalHeaders), - CURLOPT_TIMEOUT => 10, - ]; - - if ($method === 'POST') { - $curlOptions[CURLOPT_POST] = true; - } elseif ($method !== 'GET') { - $curlOptions[CURLOPT_CUSTOMREQUEST] = $method; - } - - if ($processedBody !== null && $method !== 'GET') { - $curlOptions[CURLOPT_POSTFIELDS] = $processedBody; - } - - try { - $response = FreshRSS_http_Util::httpGet($url, cachePath: null, type: 'json', curl_options: $curlOptions); - - if ($response['fail']) { - logError($logEnabled, "Request failed for URL: {$url}"); - throw new RuntimeException("HTTP request failed for URL: {$url}"); - } - - logWarning($logEnabled, "Response ✅ {$response['body']}"); - } catch (RuntimeException $err) { - throw $err; - } catch (Throwable $err) { - logError($logEnabled, "Error in sendReq: {$err->getMessage()} | URL: {$url} | Body: {$body}"); - throw $err; - } -} - -/** - * 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 - * @throws Minz_PermissionDeniedException - * - * @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(is_array($bodyObject) ? $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 - * - * @throws Minz_PermissionDeniedException - * - * @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 !== null) ? str_replace('\/', '/', $body) : ''; - $headersJson = json_encode($headers); - - $logMessage = trim("{$additionalLog} ♦♦ sendReq ⏩ {$method}: {$cleanUrl} ♦♦ {$bodyType} ♦♦ {$cleanBody} ♦♦ {$headersJson}"); - - logWarning($logEnabled, $logMessage); -} - -/** - * 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 string $data Data to log (will be converted to string) - * - * @throws Minz_PermissionDeniedException - * - * @return void - */ -function logWarning(bool $logEnabled, string $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 string $data Data to log (will be converted to string) - * - * @throws Minz_PermissionDeniedException - * - * @return void - */ -function logError(bool $logEnabled, string $data): void { - if ($logEnabled && class_exists('Minz_Log')) { - Minz_Log::error('[WEBHOOK]❌ ' . $data); - } -} From 3a9bc702183a79d4eaa92c85638076cb88f27f0c Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sun, 26 Apr 2026 22:06:24 +0200 Subject: [PATCH 15/15] Add GReader and RSS payloads And more simplification --- xExtension-Webhook/configure.phtml | 232 +++++++++++++---------------- xExtension-Webhook/extension.php | 121 ++++++++++----- xExtension-Webhook/i18n/en/ext.php | 15 +- xExtension-Webhook/i18n/fr/ext.php | 15 +- 4 files changed, 201 insertions(+), 182 deletions(-) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index 2b94bba5..e58f1a46 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -21,146 +21,124 @@ declare(strict_types=1);
- -
- -
- getUserConfigurationBool('mark_as_read') ? 'checked="checked"' : '' ?> /> -
-
-
🌐 -
- - -
- -
- - -
+
+ +
+ +
+
-
- -
- -
- - - - -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{title}
{url}
{content}
{date_published}
{date_received}
{date_modified}
{date_user_modified}
{author}
{feed_name}
{feed_url}
{tags}
-
- -
-
- -
+
+ +
+
+
-
- - -
- -
- +
+ +
+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{title}
{url}
{content}
{date_published}
{date_received}
{date_modified}
{date_user_modified}
{author}
{feed_name}
{feed_url}
{tags}
+
-
-
- -
- getUserConfigurationString('webhook_body_type') === 'json' ? 'checked="checked"' : '' ?> /> - +
+
- getUserConfigurationString('webhook_body_type') === 'form' ? 'checked="checked"' : '' ?> /> - + +
+ getUserConfigurationString('webhook_content_type') === 'json' ? 'checked="checked"' : '' ?> /> + -
- - -
-
-
-
+ getUserConfigurationString('webhook_content_type') === 'form' ? 'checked="checked"' : '' ?> /> + +
+
- +
+ +
+ +
+
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index e961032d..96d175ae 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -20,7 +20,7 @@ class WebhookExtension extends Minz_Extension { /** * Default body type for webhook requests */ - public string $webhook_body_type = 'json'; + public string $webhook_body_type = 'custom'; /** * Default webhook URL @@ -34,7 +34,7 @@ class WebhookExtension extends Minz_Extension { * * @var list */ - public array $webhook_headers = ['Content-Type: application/x-www-form-urlencoded']; + public array $webhook_headers = []; /** * Default webhook request body template @@ -70,6 +70,7 @@ public function init(): void { * sends a test webhook request. * * @return void + * @throws Minz_ConfigurationException * @throws Minz_PermissionDeniedException */ #[\Override] @@ -78,7 +79,6 @@ public function handleConfigureAction(): void { if (Minz_Request::isPost()) { $this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true))); - $this->setUserConfigurationValue('mark_as_read', Minz_Request::paramBoolean('mark_as_read')); $this->setUserConfigurationValue('ignore_updated', Minz_Request::paramBoolean('ignore_updated')); $this->setUserConfigurationValue('webhook_url', trim(Minz_Request::paramString('webhook_url', plaintext: true))); $this->setUserConfigurationValue('webhook_method', trim(Minz_Request::paramString('webhook_method', plaintext: true))); @@ -88,13 +88,14 @@ public function handleConfigureAction(): void { $webhookBodyArray = $webhookBodyJson === '' ? [] : json_decode($webhookBodyJson, true, 256, JSON_INVALID_UTF8_SUBSTITUTE); $this->setUserConfigurationValue('webhook_body', is_array($webhookBodyArray) ? $webhookBodyArray : []); $this->setUserConfigurationValue('webhook_body_type', trim(Minz_Request::paramString('webhook_body_type', plaintext: true))); + $this->setUserConfigurationValue('webhook_content_type', trim(Minz_Request::paramString('webhook_content_type', plaintext: true))); if (Minz_Request::paramString('test_request', plaintext: true) !== '') { try { $this->sendRequest( $this->getUserConfigurationString('webhook_url') ?? '', $this->getUserConfigurationString('webhook_method') ?? '', - $this->getUserConfigurationString('webhook_body_type') ?? '', + $this->getUserConfigurationString('webhook_content_type') ?? 'json', $this->getUserConfigurationArray('webhook_body') ?? [], array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), ); @@ -120,16 +121,10 @@ public function processArticle(FreshRSS_Entry $entry): FreshRSS_Entry { return $entry; } - $markAsRead = $this->getUserConfigurationBool('mark_as_read') ?? false; - try { if (!$this->entryMatchesSearchFilter($entry)) { return $entry; } - if ($markAsRead) { - $entry->_isRead(true); - } - $this->sendArticle($entry); } catch (Throwable $err) { Minz_Log::warning('[Webhook] Error processing article: ' . $err->getMessage()); @@ -196,66 +191,114 @@ private function replacePlaceholdersRecursive(array $data, array $replacements): * Send article data via webhook * * Prepares and sends webhook notification with article data. - * Replaces template placeholders with actual entry values. + * Supports custom body templates, GReader API JSON, and RSS XML formats. * * @param FreshRSS_Entry $entry The RSS entry to send * @throws \RuntimeException + * @throws Minz_ConfigurationException */ private function sendArticle(FreshRSS_Entry $entry): void { - $body = $this->getUserConfigurationArray('webhook_body') ?? $this->webhook_body; - - // Replace placeholders with actual values - $replacements = [ - '{title}' => htmlspecialchars_decode($entry->title(), ENT_QUOTES), - '{feed_name}' => htmlspecialchars_decode($entry->feed()?->name() ?? '', ENT_QUOTES), - '{feed_url}' => htmlspecialchars_decode($entry->feed()?->url() ?? '', ENT_QUOTES), - '{url}' => htmlspecialchars_decode($entry->link(), ENT_QUOTES), - '{content}' => htmlspecialchars_decode($entry->content(), ENT_QUOTES), - '{date_published}' => timestampToMachineDate($entry->date(raw: true)), - '{date_received}' => timestampToMachineDate($entry->dateAdded(raw: true)), - '{date_modified}' => $entry->lastModified() === null ? null : timestampToMachineDate($entry->lastModified()), - '{date_user_modified}' => $entry->lastUserModified() === null ? null : timestampToMachineDate($entry->lastUserModified()), - '{author}' => htmlspecialchars_decode($entry->authors(true), ENT_QUOTES), - '{tags}' => htmlspecialchars_decode($entry->tags(true), ENT_QUOTES), - ]; - - $body = $this->replacePlaceholdersRecursive($body, $replacements); + $bodyType = $this->getUserConfigurationString('webhook_body_type') ?? ''; + + switch ($bodyType) { + case 'greader': + $body = $entry->toGReader(); + $contentType = 'json'; + break; + case 'rss': + $body = $this->renderEntryAsRss($entry); + $contentType = 'rss'; + break; + default: + $contentType = $this->getUserConfigurationString('webhook_content_type') ?? 'json'; + $body = $this->getUserConfigurationArray('webhook_body') ?? $this->webhook_body; + + // Replace placeholders with actual values + $replacements = [ + '{title}' => htmlspecialchars_decode($entry->title(), ENT_QUOTES), + '{feed_name}' => htmlspecialchars_decode($entry->feed()?->name() ?? '', ENT_QUOTES), + '{feed_url}' => htmlspecialchars_decode($entry->feed()?->url() ?? '', ENT_QUOTES), + '{url}' => htmlspecialchars_decode($entry->link(), ENT_QUOTES), + '{content}' => htmlspecialchars_decode($entry->content(), ENT_QUOTES), + '{date_published}' => timestampToMachineDate($entry->date(raw: true)), + '{date_received}' => timestampToMachineDate($entry->dateAdded(raw: true)), + '{date_modified}' => $entry->lastModified() === null ? null : timestampToMachineDate($entry->lastModified()), + '{date_user_modified}' => $entry->lastUserModified() === null ? null : timestampToMachineDate($entry->lastUserModified()), + '{author}' => htmlspecialchars_decode($entry->authors(true), ENT_QUOTES), + '{tags}' => htmlspecialchars_decode($entry->tags(true), ENT_QUOTES), + ]; + + $body = $this->replacePlaceholdersRecursive($body, $replacements); + break; + } $this->sendRequest( $this->getUserConfigurationString('webhook_url') ?? '', $this->getUserConfigurationString('webhook_method') ?? '', - $this->getUserConfigurationString('webhook_body_type') ?? '', + $contentType, $body, array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), ); } + /** + * Render an entry as RSS XML using the FreshRSS RSS view template + * + * @param FreshRSS_Entry $entry The RSS entry to render + * @return string The rendered RSS XML string + * @throws Minz_ConfigurationException + */ + private function renderEntryAsRss(FreshRSS_Entry $entry): string { + $view = new FreshRSS_View(); + $view->entries = [$entry]; + $view->internal_rendering = true; + $view->publishLabelsInsteadOfTags = false; + $view->entryIdsTagNames = []; + $view->rss_base = ''; + $view->image_url = ''; + + $feed = $entry->feed(); + $view->rss_title = $feed !== null ? htmlspecialchars_decode($feed->name(), ENT_QUOTES) : ''; + $view->html_url = $feed !== null ? htmlspecialchars_decode($feed->website()) : ''; + $view->rss_url = $feed !== null ? htmlspecialchars_decode($feed->url()) : ''; + $view->description = ''; + + $view->_layout(null); + $view->_path('index/rss.phtml'); + return $view->renderToString(); + } + /** * Send an HTTP request via the FreshRSS HTTP utility * * @param string $url Target URL * @param string $method HTTP method (GET, POST, PUT, etc.) - * @param string $bodyType Content type ('json' or 'form') - * @param array $body Request body as an array + * @param string $contentType Content type ('json', 'form', or 'rss') + * @param array|string $body Request body as an array or string * @param list $headers HTTP headers * @throws \RuntimeException */ - private function sendRequest(string $url, string $method, string $bodyType, array $body, array $headers = []): void { + private function sendRequest(string $url, string $method, string $contentType, array|string $body, array $headers = []): void { if ($url === '') { throw new RuntimeException('Webhook URL is empty'); } $processedBody = null; - if ($body !== [] && $method !== 'GET') { - $processedBody = match ($bodyType) { - 'form' => http_build_query($body), - default => json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), - }; + if ($body !== '' && $body !== [] && $method !== 'GET') { + if (is_string($body)) { + $processedBody = $body; + } else { + $processedBody = match ($contentType) { + 'form' => http_build_query($body), + default => json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + }; + } } if (empty($headers)) { - $headers = match ($bodyType) { + $headers = match ($contentType) { 'form' => ['Content-Type: application/x-www-form-urlencoded'], + 'rss' => ['Content-Type: application/rss+xml; charset=utf-8'], default => ['Content-Type: application/json'], }; } diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index 912a577f..29eabee0 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -3,16 +3,12 @@ return array( 'webhook' => array( 'event_settings' => 'Event settings', - 'show_hide' => 'show/hide', + '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.', + 'description' => 'Webhooks allow external services to be notified when certain events happen.
When the specified events happen, we’ll send a HTTP request (usually POST) to the URL you provide.', 'search_filter' => 'Search filter', 'search_filter_description' => 'Uses FreshRSS search filter syntax. Each line is an OR condition. Leave empty to match all entries.', - '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:', @@ -31,7 +27,10 @@ 'http_body_placeholder_date_user_modified_description' => 'Last user-modified date (ISO 8601, null if never modified)', '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' + 'http_content_type' => 'Content encoding', + 'custom_body_type' => 'Custom', + 'greader_body_type' => 'GReader API (JSON)', + 'greader_body_type_description' => 'Sends the full article as a GReader API-compatible JSON object.', + 'rss_body_type' => 'RSS (XML)', ), ); diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php index 72b6bc36..1c358926 100644 --- a/xExtension-Webhook/i18n/fr/ext.php +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -3,16 +3,12 @@ return array( 'webhook' => array( 'event_settings' => 'Paramètres des événements', - 'show_hide' => 'afficher/masquer', + 'show_hide' => 'Afficher/masquer', 'webhook_settings' => 'Paramètres du webhook', - 'more_options' => 'Plus d’options (en-têtes, format,…) :', 'save_and_send_test_req' => 'Enregistrer et envoyer une requête de test', - 'description' => 'Les webhooks permettent de notifier des services externes lorsque certains événements se produisent.\nLorsque les événements spécifiés se produisent, nous envoyons une requête HTTP (généralement POST) à l’URL que vous fournissez.', + 'description' => 'Les webhooks permettent de notifier des services externes lorsque certains événements se produisent.
Lorsque les événements spécifiés se produisent, nous envoyons une requête HTTP (généralement POST) à l’URL que vous fournissez.', 'search_filter' => 'Filtre de recherche', 'search_filter_description' => 'Utilise la syntaxe de filtre de recherche FreshRSS. Chaque ligne est une condition OU. Laisser vide pour correspondre à tous les articles.', - 'mark_as_read' => 'Marquer comme lu', - 'mark_as_read_description' => 'Marquer l’article comme lu après l’envoi du webhook.', - 'mark_as_read_label' => 'Marquer comme lu', 'http_body' => 'Corps HTTP', 'http_body_description' => 'Doit être du JSON valide ou des données de formulaire (x-www-form-urlencoded)', 'http_body_placeholder_summary' => 'Vous pouvez utiliser des espaces réservés spéciaux qui seront remplacés par les valeurs réelles :', @@ -31,7 +27,10 @@ 'http_body_placeholder_date_user_modified_description' => 'Date de dernière modification par l’utilisateur (ISO 8601, null si jamais modifié)', 'webhook_headers' => 'En-têtes HTTP
(un par ligne)', 'http_body_type' => 'Type de corps HTTP', - 'more_info' => 'Plus d’informations :', - 'more_info_description' => 'Lorsque l’en-tête contient Content-type: application/x-www-form-urlencoded, les clés et les valeurs sont encodées en tuples clé-valeur séparés par « & », avec un « = » entre la clé et la valeur. Les caractères non alphanumériques dans les clés et les valeurs sont encodés en URL', + 'http_content_type' => 'Encodage du contenu', + 'custom_body_type' => 'Personnalisé', + 'greader_body_type' => 'GReader API (JSON)', + 'greader_body_type_description' => 'Envoie l’article complet sous forme d’objet JSON compatible avec l’API GReader.', + 'rss_body_type' => 'RSS (XML)', ), );