diff --git a/xExtension-Webhook/LICENSE b/xExtension-Webhook/LICENSE new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/xExtension-Webhook/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md new file mode 100644 index 00000000..5f3f7573 --- /dev/null +++ b/xExtension-Webhook/README.md @@ -0,0 +1,242 @@ +# FreshRSS Webhook Extension + +[](https://www.gnu.org/licenses/agpl-3.0) +[](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) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 00000000..824f0099 --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,199 @@ + + + = _t('ext.webhook.description') ?> + +
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 00000000..598a989a --- /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); +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 00000000..ced0e41d --- /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 functionpreg_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 HeadersContent-type: application/x-www-form-urlencoded the keys and values are encoded in key-value tuples separated by "&", with a "=" between the key and the value. Non-alphanumeric characters in both keys and values are URL encoded'
+ ),
+);
diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json
new file mode 100644
index 00000000..6cba9dde
--- /dev/null
+++ b/xExtension-Webhook/metadata.json
@@ -0,0 +1,8 @@
+{
+ "name": "Webhook",
+ "author": "Lukas Melega, Ryahn",
+ "description": "Send custom webhook when new article appears (and matches custom criteria)",
+ "version": "0.1.1",
+ "entrypoint": "Webhook",
+ "type": "system"
+}
diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php
new file mode 100644
index 00000000..5a4143d1
--- /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);
+}