From fd5f40a366d873b6ba5788c7a70eee0062a12197 Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 17:41:30 +0800 Subject: [PATCH 1/7] Refine webhook extension configuration --- xExtension-Webhook/README.md | 16 +- xExtension-Webhook/configure.phtml | 27 +- xExtension-Webhook/extension.php | 855 ++++++++++++++++++----------- xExtension-Webhook/i18n/en/ext.php | 6 +- xExtension-Webhook/metadata.json | 2 +- xExtension-Webhook/request.php | 44 +- 6 files changed, 606 insertions(+), 344 deletions(-) diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index 5f3f7573..e05eb0f2 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -12,9 +12,11 @@ A powerful FreshRSS extension that automatically sends webhook notifications whe - **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 +- **Update-aware Filters**: Ignore updates to existing entries to avoid duplicate notifications +- **Comprehensive Logging**: Optional, privacy-aware logging for debugging and monitoring +- **Test Functionality**: Built-in “Save and send test request” button to verify configuration instantly +- **Thumbnail Placeholder**: Populate payloads with `__THUMBNAIL_URL__` for embeds and cards - **Error Handling**: Robust error handling with graceful fallbacks -- **Test Functionality**: Built-in test feature to verify webhook configuration ## 📋 Requirements @@ -51,14 +53,21 @@ your-project-name - **Search in Feed**: Match keywords in feed names - **Search in Authors**: Match keywords in author names - **Search in Content**: Match keywords in article content +- **Ignore Updated Entries**: Skip webhooks when an existing entry is updated +- **Mark as Read**: Automatically mark matched entries as read after the webhook is sent #### Webhook Settings - **Webhook URL**: Your webhook endpoint URL -- **HTTP Method**: Choose from GET, POST, PUT, DELETE, etc. +- **HTTP Method**: Choose from GET, POST, PUT, DELETE, PATCH, OPTIONS, or HEAD - **Body Type**: JSON or Form-encoded - **Headers**: Custom HTTP headers (one per line) +### Logging & Testing + +- **Enable Logging**: Toggle HTTP request/response logging (writes to FreshRSS logs) +- **Save and Send Test Request**: Saves the configuration and immediately sends a test payload using the provided settings + ### Webhook Body Template Customize the webhook payload using placeholders: @@ -88,6 +97,7 @@ Customize the webhook payload using placeholders: | `__DATE_TIMESTAMP__` | Unix timestamp | | `__AUTHORS__` | Article authors | | `__TAGS__` | Article tags | +| `__THUMBNAIL_URL__` | First enclosure thumbnail or article thumbnail | ## 🎯 Use Cases diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index 824f0099..6e6a3f3e 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -63,6 +63,15 @@ declare(strict_types=1); +
+ +
+ attributeBool('ignore_updated') ? 'checked="checked"' : '' ?> /> + +
+
+ @@ -78,9 +87,8 @@ declare(strict_types=1);
@@ -182,6 +190,15 @@ declare(strict_types=1);
+ +
+ +
+ attributeBool('enable_logging') ? 'checked="checked"' : '' ?> /> + +
+
@@ -192,7 +209,9 @@ declare(strict_types=1);
- +
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 598a989a..4cadcb7d 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -2,452 +2,665 @@ declare(strict_types=1); -include __DIR__ . "/request.php"; +include __DIR__ . '/request.php'; /** - * Enumeration for HTTP request body types - * - * Defines the supported content types for webhook request bodies. + * Enumeration for HTTP request body types. */ enum BODY_TYPE: string { - case JSON = "json"; - case FORM = "form"; + case JSON = 'json'; + case FORM = 'form'; } /** - * Enumeration for HTTP methods - * - * Defines the supported HTTP methods for webhook requests. + * Enumeration for HTTP methods. */ 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'; } /** * 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. + * Sends configurable webhook requests whenever new entries match the + * configured keyword filters. * * @author Lukas Melega, Ryahn - * @version 0.1.1 + * @version 0.2.0 * @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; - - /** - * Default body type for webhook requests - * - * @var BODY_TYPE - */ - public BODY_TYPE $webhook_body_type = BODY_TYPE::JSON; - - /** - * Default webhook URL - * - * @var string - */ - 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"]; - - /** - * Default webhook request body template - * - * Supports placeholders like __TITLE__, __FEED__, __URL__, etc. - * - * @var string - */ - public string $webhook_body = '{ +final class WebhookExtension extends Minz_Extension { + private const DEFAULT_URL = 'http://'; + private const DEFAULT_HEADERS = [ + 'User-Agent: FreshRSS', + 'Content-Type: application/json', + ]; + private const DEFAULT_BODY_TEMPLATE = '{ "title": "__TITLE__", "feed": "__FEED__", "url": "__URL__", "created": "__DATE_TIMESTAMP__" }'; + private const DEFAULT_METHOD = HTTP_METHOD::POST; + private const DEFAULT_BODY_TYPE = BODY_TYPE::JSON; + private const JSON_LOG_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + private bool $logsEnabled = false; - /** - * 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"]); + $this->ensureConfigurationDefaults(); + $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 - */ + #[\Override] 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()}"); - } + if (!Minz_Request::isPost()) { + return; + } + + $userConf = $this->getUserConf(); + if ($userConf === null) { + throw new Minz_PermissionDeniedException('Webhook configuration requires an authenticated user.'); + } + + $config = $this->collectConfigurationFromRequest(); + + foreach ($config as $key => $value) { + $userConf->_attribute($key, $value); + } + + $userConf->save(); + + $this->logsEnabled = (bool) ($config['enable_logging'] ?? false); + + $loggable = $config; + $loggable['webhook_body'] = '[redacted]'; + logWarning( + $this->logsEnabled, + 'Webhook configuration saved: ' . json_encode($loggable, self::JSON_LOG_FLAGS) + ); + + if ($this->shouldSendTestRequest()) { + try { + $this->sendTestRequest($config); + } catch (Throwable $err) { + logError($this->logsEnabled, 'Test webhook 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. + * Process article and send webhook if patterns match. * - * @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) + * @param FreshRSS_Entry|mixed $entry */ public function processArticle($entry): FreshRSS_Entry { - if (!is_object($entry)) { + if (!$entry instanceof FreshRSS_Entry) { return $entry; } - if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) { - logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + $config = $this->getSnapshot(); + if ($config === null) { 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; + $this->logsEnabled = $config['enable_logging']; - $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."); + if ($config['ignore_updated'] && $entry->isUpdated()) { + logWarning( + $this->logsEnabled, + 'Ignoring updated entry: ' . $entry->link() . ' ♦ ' . $entry->title() + ); return $entry; } - $title = "❗️NOT INITIALIZED"; - $link = "❗️NOT INITIALIZED"; - $additionalLog = ""; - - try { - $title = $entry->title(); - $link = $entry->link(); + $patterns = $config['keywords']; + if ($patterns === []) { + logWarning($this->logsEnabled, 'No keywords defined in Webhook extension settings.'); + return $entry; + } - foreach ($patterns as $pattern) { - $matchFound = false; + $matchLog = $this->findMatchLog($entry, $patterns, $config); + if ($matchLog === null) { + return $entry; + } - 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 ($config['mark_as_read']) { + $entry->_isRead(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; - } + $this->sendArticle($entry, $matchLog, $config); - 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; - } + return $entry; + } - 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; - } + /** + * Try to find a pattern that matches this entry. + * + * @param array $patterns + * @param array $config + */ + private function findMatchLog(FreshRSS_Entry $entry, array $patterns, array $config): ?string { + $title = (string) $entry->title(); + $link = (string) $entry->link(); + $feed = $entry->feed(); + $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; + $authors = trim((string) $entry->authors(true)); + $content = (string) $entry->content(); + + foreach ($patterns as $pattern) { + $normalizedPattern = $this->normalizePattern($pattern); + if ($normalizedPattern === null) { + continue; + } - if ($matchFound) { - break; - } + if ($config['search_in_title'] && $this->isPatternFound($normalizedPattern, $title, $pattern)) { + return "Matched by title ⮕ pattern: {$pattern} ♦ title: {$title} ♦ link: {$link}"; } - if ($markAsRead) { - $entry->_isRead($markAsRead); + if ( + $config['search_in_feed'] + && $feedName !== '' + && $this->isPatternFound($normalizedPattern, $feedName, $pattern) + ) { + return "Matched by feed ⮕ pattern: {$pattern} ♦ feed: {$feedName} ♦ link: {$link}"; } - // Only send webhook if a pattern was matched - if (!empty($additionalLog)) { - $this->sendArticle($entry, $additionalLog); + if ( + $config['search_in_authors'] + && $authors !== '' + && $this->isPatternFound($normalizedPattern, $authors, $pattern) + ) { + return "Matched by authors ⮕ pattern: {$pattern} ♦ authors: {$authors} ♦ link: {$link}"; } - } catch (Throwable $err) { - logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}"); + if ( + $config['search_in_content'] + && $content !== '' + && $this->isPatternFound($normalizedPattern, $content, $pattern) + ) { + return "Matched by content ⮕ pattern: {$pattern} ♦ title: {$title} ♦ link: {$link}"; + } } - return $entry; + return null; } /** - * Send article data via webhook - * - * Prepares and sends webhook notification with article data. - * Replaces template placeholders with actual entry values. + * Send article data via webhook. * - * @param FreshRSS_Entry $entry The RSS entry to send - * @param string $additionalLog Additional context for logging - * - * @throws Minz_PermissionDeniedException - * - * @return void + * @param array $config */ - private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { + private function sendArticle(FreshRSS_Entry $entry, string $additionalLog, array $config): 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); + $bodyTemplate = (string) $config['webhook_body']; + $replacements = $this->buildReplacements($entry); + $body = str_replace(array_keys($replacements), array_values($replacements), $bodyTemplate); 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'), + (string) $config['webhook_url'], + (string) $config['webhook_method'], + (string) $config['webhook_body_type'], + $body, + $config['webhook_headers'], + (bool) $config['enable_logging'], $additionalLog, ); } catch (Throwable $err) { - logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}"); + logError($this->logsEnabled, 'sendArticle error: ' . $err->getMessage()); } } /** - * Convert string/int to safe JSON string + * Send a manual test request using configuration data. * - * Sanitizes input values for safe inclusion in JSON payloads - * by removing quotes and decoding HTML entities. - * - * @param string|int $str Input value to sanitize + * @param array $config + */ + private function sendTestRequest(array $config): void { + sendReq( + (string) $config['webhook_url'], + (string) $config['webhook_method'], + (string) $config['webhook_body_type'], + (string) $config['webhook_body'], + $config['webhook_headers'], + (bool) $config['enable_logging'], + 'Test request from configuration', + ); + } + + /** + * Build placeholder replacements for the configured template. * - * @return string Sanitized string safe for JSON inclusion + * @return array + */ + private function buildReplacements(FreshRSS_Entry $entry): array { + $feed = $entry->feed(); + $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; + + return [ + '__TITLE__' => $this->toSafeJsonStr($entry->title()), + '__FEED__' => $this->toSafeJsonStr($feedName), + '__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)), + '__THUMBNAIL_URL__' => $this->toSafeJsonStr($this->getEntryThumbnail($entry)), + ]; + } + + /** + * Convert a mixed value to a JSON-safe string. + */ + private function toSafeJsonStr(mixed $value): string { + if ($value === null) { + return ''; + } + + if ($value instanceof DateTimeInterface) { + return $value->format(DateTimeInterface::ATOM); + } + + if (is_array($value)) { + $value = implode(', ', array_map(static fn ($item): string => (string) $item, $value)); + } elseif (is_object($value)) { + if (method_exists($value, '__toString')) { + $value = (string) $value; + } else { + return ''; + } + } + + $string = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $string = preg_replace('/\s+/u', ' ', $string) ?? ''; + + return addcslashes(trim($string), "\"\\"); + } + + /** + * Ensure configuration defaults exist for the current user. */ - private function toSafeJsonStr(string|int $str): string { - if (is_numeric($str)) { - return (string)$str; + private function ensureConfigurationDefaults(): void { + $userConf = $this->getUserConf(); + if ($userConf === null) { + return; + } + + $needsSave = $this->migrateLegacyConfiguration($userConf); + + if ($userConf->attributeArray('keywords') === null) { + $userConf->_attribute('keywords', []); + $needsSave = true; + } + + $needsSave = $this->ensureBoolDefault($userConf, 'search_in_title', true) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'search_in_feed', false) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'search_in_authors', false) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'search_in_content', false) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'mark_as_read', false) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'ignore_updated', false) || $needsSave; + $needsSave = $this->ensureBoolDefault($userConf, 'enable_logging', false) || $needsSave; + + if ($userConf->attributeString('webhook_url') === null) { + $userConf->_attribute('webhook_url', self::DEFAULT_URL); + $needsSave = true; + } + + if ($userConf->attributeString('webhook_method') === null) { + $userConf->_attribute('webhook_method', self::DEFAULT_METHOD->value); + $needsSave = true; + } + + if ($userConf->attributeArray('webhook_headers') === null) { + $userConf->_attribute('webhook_headers', self::DEFAULT_HEADERS); + $needsSave = true; + } + + if ($userConf->attributeString('webhook_body') === null) { + $userConf->_attribute('webhook_body', self::DEFAULT_BODY_TEMPLATE); + $needsSave = true; + } + + if ($userConf->attributeString('webhook_body_type') === null) { + $userConf->_attribute('webhook_body_type', self::DEFAULT_BODY_TYPE->value); + $needsSave = true; } - // Remove quotes and decode HTML entities - return str_replace('"', '', html_entity_decode((string)$str)); + if ($needsSave) { + $userConf->save(); + } } /** - * 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 + * Attempt to migrate settings stored via the legacy system configuration. */ - private function isPatternFound(string $pattern, string $text): bool { - if (empty($text) || empty($pattern)) { + private function migrateLegacyConfiguration(FreshRSS_UserConfiguration $userConf): bool { + if (!method_exists($this, 'getSystemConfiguration')) { return false; } - try { - // Try regex match first - if (preg_match($pattern, $text) === 1) { - return true; + $legacy = $this->getSystemConfiguration(); + if (!is_array($legacy) || $legacy === []) { + return false; + } + + $needsSave = false; + $map = [ + 'keywords' => ['type' => 'array'], + 'search_in_title' => ['type' => 'bool'], + 'search_in_feed' => ['type' => 'bool'], + 'search_in_authors' => ['type' => 'bool'], + 'search_in_content' => ['type' => 'bool'], + 'mark_as_read' => ['type' => 'bool'], + 'ignore_updated' => ['type' => 'bool'], + 'webhook_url' => ['type' => 'string'], + 'webhook_method' => ['type' => 'string'], + 'webhook_headers' => ['type' => 'array'], + 'webhook_body' => ['type' => 'string'], + 'webhook_body_type' => ['type' => 'string'], + 'enable_logging' => ['type' => 'bool'], + ]; + + foreach ($map as $key => $meta) { + if (!array_key_exists($key, $legacy)) { + continue; } - // Fallback to string search (remove regex delimiters) - $cleanPattern = trim($pattern, '/'); - return str_contains($text, $cleanPattern); + if ($this->hasAttributeValue($userConf, $key, $meta['type'])) { + continue; + } - } catch (Throwable $err) { - logError($this->logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err->getMessage()}"); - return false; + $userConf->_attribute($key, $legacy[$key]); + $needsSave = true; + } + + if ($needsSave) { + logWarning(true, 'Webhook settings migrated from system scope to per-user scope.'); + } + + return $needsSave; + } + + private function hasAttributeValue(FreshRSS_UserConfiguration $userConf, string $key, string $type): bool { + return match ($type) { + 'array' => $userConf->attributeArray($key) !== null, + 'bool' => $userConf->attributeBool($key) !== null, + default => $userConf->attributeString($key) !== null, + }; + } + + private function ensureBoolDefault(FreshRSS_UserConfiguration $userConf, string $key, bool $default): bool { + if ($userConf->attributeBool($key) === null) { + $userConf->_attribute($key, $default); + return true; + } + + return false; + } + + private function getUserConf(): ?FreshRSS_UserConfiguration { + try { + if (!FreshRSS_Context::hasUserConf()) { + return null; + } + + return FreshRSS_Context::userConf(); + } catch (FreshRSS_Context_Exception $exception) { + return null; } } /** - * Get keywords configuration as formatted string - * - * Returns the configured keywords as a newline-separated string - * for display in the configuration form. + * Collect configuration values from the current request payload. * - * @throws FreshRSS_Context_Exception - * - * @return string Keywords separated by newlines + * @return array */ - public function getKeywordsData(): string { - $keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; - return implode(PHP_EOL, $keywords); + private function collectConfigurationFromRequest(): array { + $keywords = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords')); + $headers = $this->normalizeListInput(Minz_Request::paramTextToArray('webhook_headers')); + $headers = $headers === [] ? self::DEFAULT_HEADERS : $headers; + + $methodValue = HTTP_METHOD::tryFrom(strtoupper(Minz_Request::paramString('webhook_method'))); + $bodyTypeValue = BODY_TYPE::tryFrom(strtolower(Minz_Request::paramString('webhook_body_type'))); + + return [ + 'keywords' => $keywords, + 'search_in_title' => Minz_Request::paramBoolean('search_in_title'), + 'search_in_feed' => Minz_Request::paramBoolean('search_in_feed'), + 'search_in_authors' => Minz_Request::paramBoolean('search_in_authors'), + 'search_in_content' => Minz_Request::paramBoolean('search_in_content'), + 'mark_as_read' => Minz_Request::paramBoolean('mark_as_read'), + 'ignore_updated' => Minz_Request::paramBoolean('ignore_updated'), + 'webhook_url' => trim(Minz_Request::paramString('webhook_url')), + 'webhook_method' => ($methodValue ?? self::DEFAULT_METHOD)->value, + 'webhook_headers' => $headers, + 'webhook_body' => html_entity_decode(Minz_Request::paramString('webhook_body'), ENT_QUOTES | ENT_HTML5), + 'webhook_body_type' => ($bodyTypeValue ?? self::DEFAULT_BODY_TYPE)->value, + 'enable_logging' => Minz_Request::paramBoolean('enable_logging'), + ]; + } + + private function shouldSendTestRequest(): bool { + return Minz_Request::paramBoolean('test_request'); } /** - * Get webhook headers configuration as formatted string - * - * Returns the configured HTTP headers as a newline-separated string - * for display in the configuration form. + * Return the configuration snapshot for the current user. * - * @throws FreshRSS_Context_Exception - * - * @return string HTTP headers separated by newlines + * @return array|null */ - public function getWebhookHeaders(): string { - $headers = FreshRSS_Context::userConf()->attributeArray('webhook_headers'); - return implode( - PHP_EOL, - is_array($headers) ? $headers : ($this->webhook_headers ?? []), - ); + private function getSnapshot(): ?array { + $userConf = $this->getUserConf(); + if ($userConf === null) { + return null; + } + + return [ + 'keywords' => $this->getArrayAttribute($userConf, 'keywords', []), + 'search_in_title' => $this->getBoolAttribute($userConf, 'search_in_title', true), + 'search_in_feed' => $this->getBoolAttribute($userConf, 'search_in_feed', false), + 'search_in_authors' => $this->getBoolAttribute($userConf, 'search_in_authors', false), + 'search_in_content' => $this->getBoolAttribute($userConf, 'search_in_content', false), + 'mark_as_read' => $this->getBoolAttribute($userConf, 'mark_as_read', false), + 'ignore_updated' => $this->getBoolAttribute($userConf, 'ignore_updated', false), + 'webhook_headers' => $this->getArrayAttribute($userConf, 'webhook_headers', self::DEFAULT_HEADERS), + 'webhook_url' => $this->getStringAttribute($userConf, 'webhook_url', self::DEFAULT_URL), + 'webhook_method' => $this->normalizeMethodValue($userConf->attributeString('webhook_method')), + 'webhook_body' => $this->getStringAttribute($userConf, 'webhook_body', self::DEFAULT_BODY_TEMPLATE), + 'webhook_body_type' => $this->normalizeBodyTypeValue($userConf->attributeString('webhook_body_type')), + 'enable_logging' => $this->getBoolAttribute($userConf, 'enable_logging', false), + ]; + } + + private function normalizeMethodValue(?string $method): string { + return (HTTP_METHOD::tryFrom(strtoupper((string) $method)) ?? self::DEFAULT_METHOD)->value; + } + + private function normalizeBodyTypeValue(?string $bodyType): string { + return (BODY_TYPE::tryFrom(strtolower((string) $bodyType)) ?? self::DEFAULT_BODY_TYPE)->value; } /** - * 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 + * @return string[] */ - public function getWebhookUrl(): string { - return FreshRSS_Context::userConf()->attributeString('webhook_url') ?? $this->webhook_url; + private function getArrayAttribute(FreshRSS_UserConfiguration $userConf, string $key, array $default): array { + $value = $userConf->attributeArray($key); + if (!is_array($value)) { + return $default; + } + + $normalized = []; + foreach ($value as $item) { + $trimmed = trim((string) $item); + if ($trimmed !== '') { + $normalized[] = $trimmed; + } + } + + return $normalized; + } + + private function getBoolAttribute(FreshRSS_UserConfiguration $userConf, string $key, bool $default): bool { + $value = $userConf->attributeBool($key); + return $value ?? $default; + } + + private function getStringAttribute(FreshRSS_UserConfiguration $userConf, string $key, string $default): string { + $value = $userConf->attributeString($key); + return ($value === null || $value === '') ? $default : $value; } /** - * Get configured webhook body template - * - * Returns the configured webhook body template or the default if none is set. - * - * @throws FreshRSS_Context_Exception + * Normalize a list input (textarea) into trimmed values. * - * @return string The webhook body template + * @param array|null $values + * @return string[] */ + private function normalizeListInput(?array $values): array { + if (!is_array($values)) { + return []; + } + + $result = []; + foreach ($values as $value) { + $trimmed = trim((string) $value); + if ($trimmed !== '') { + $result[] = $trimmed; + } + } + + return $result; + } + + private function normalizePattern(string $pattern): ?string { + $pattern = trim($pattern); + if ($pattern === '') { + return null; + } + + if ($pattern[0] === '/' && strrpos($pattern, '/', 1) !== false) { + return $pattern; + } + + return '/' . preg_quote($pattern, '/') . '/i'; + } + + private function isPatternFound(string $pattern, string $text, string $fallback): bool { + if ($pattern === '' || $text === '') { + return false; + } + + $result = @preg_match($pattern, $text); + if ($result === 1) { + return true; + } + + if ($result === false) { + logError($this->logsEnabled, 'Invalid regex pattern: ' . $pattern); + } + + $fallbackNeedle = $fallback !== '' ? $fallback : $pattern; + return $fallbackNeedle !== '' && str_contains($text, $fallbackNeedle); + } + + private function getEntryThumbnail(FreshRSS_Entry $entry): string { + if (method_exists($entry, 'thumbnail')) { + $thumbnail = $entry->thumbnail(); + if (is_string($thumbnail) && $thumbnail !== '') { + return $thumbnail; + } + } + + if (method_exists($entry, 'enclosures')) { + $enclosures = $entry->enclosures(); + if (is_array($enclosures)) { + foreach ($enclosures as $enclosure) { + $url = $this->extractEnclosureUrl($enclosure); + if ($url !== '') { + return $url; + } + } + } + } + + return ''; + } + + private function extractEnclosureUrl(mixed $enclosure): string { + if (!is_object($enclosure)) { + return ''; + } + + $url = $this->getEnclosureValue($enclosure, 'url'); + if ($url === '') { + return ''; + } + + $type = $this->getEnclosureValue($enclosure, 'type'); + if ($type === '' || stripos($type, 'image/') === 0) { + return $url; + } + + return ''; + } + + private function getEnclosureValue(object $enclosure, string $name): string { + if (method_exists($enclosure, $name)) { + $value = $enclosure->{$name}(); + return is_string($value) ? $value : (string) $value; + } + + if (isset($enclosure->{$name})) { + $value = $enclosure->{$name}; + return is_string($value) ? $value : (string) $value; + } + + return ''; + } + + public function getKeywordsData(): string { + $config = $this->getSnapshot(); + $keywords = $config['keywords'] ?? []; + return implode(PHP_EOL, $keywords); + } + + public function getWebhookHeaders(): string { + $config = $this->getSnapshot(); + $headers = $config['webhook_headers'] ?? self::DEFAULT_HEADERS; + return implode(PHP_EOL, $headers); + } + + public function getWebhookUrl(): string { + $config = $this->getSnapshot(); + return $config['webhook_url'] ?? self::DEFAULT_URL; + } + public function getWebhookBody(): string { - $body = FreshRSS_Context::userConf()->attributeString('webhook_body'); - return ($body === null || $body === '') ? $this->webhook_body : $body; + $config = $this->getSnapshot(); + return $config['webhook_body'] ?? self::DEFAULT_BODY_TEMPLATE; } - /** - * 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; + $config = $this->getSnapshot(); + return $config['webhook_body_type'] ?? self::DEFAULT_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 index ced0e41d..44c77f42 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -23,6 +23,8 @@ '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', + 'ignore_updated' => 'Ignore updated entries', + 'ignore_updated_description' => 'Skip webhook delivery when FreshRSS updates an existing entry.', '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:', @@ -40,6 +42,8 @@ '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' + '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', + 'enable_logging' => 'Enable logging', + 'enable_logging_description' => 'Write webhook requests and responses to the FreshRSS logs (includes URLs and HTTP status codes).' ), ); diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json index 6cba9dde..6fd13bf4 100644 --- a/xExtension-Webhook/metadata.json +++ b/xExtension-Webhook/metadata.json @@ -2,7 +2,7 @@ "name": "Webhook", "author": "Lukas Melega, Ryahn", "description": "Send custom webhook when new article appears (and matches custom criteria)", - "version": "0.1.1", + "version": "0.2.0", "entrypoint": "Webhook", "type": "system" } diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index 5a4143d1..fc851289 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -92,22 +92,17 @@ function sendReq( */ function configureHttpMethod(CurlHandle $ch, string $method): void { curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); 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'); + curl_setopt($ch, CURLOPT_HTTPGET, true); break; - case 'DELETE': - case 'PATCH': - case 'OPTIONS': case 'HEAD': - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_NOBODY, true); break; } } @@ -130,7 +125,7 @@ function configureHttpMethod(CurlHandle $ch, string $method): void { * @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') { + if ($body === '' || $method === 'GET' || $method === 'HEAD') { return null; } @@ -138,7 +133,7 @@ function processHttpBody(string $body, string $bodyType, string $method, bool $l $bodyObject = json_decode($body, true, 256, JSON_THROW_ON_ERROR); return match ($bodyType) { - 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR), + 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'form' => http_build_query($bodyObject ?? []), default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}") }; @@ -160,15 +155,36 @@ function processHttpBody(string $body, string $bodyType, string $method, bool $l * @return string[] Final array of headers to use */ function configureHeaders(array $headers, string $bodyType): array { - if (empty($headers)) { + $normalized = []; + foreach ($headers as $header) { + $trimmed = trim((string) $header); + if ($trimmed !== '') { + $normalized[] = $trimmed; + } + } + + if ($normalized === []) { return match ($bodyType) { 'form' => ['Content-Type: application/x-www-form-urlencoded'], - 'json' => ['Content-Type: application/json'], - default => [] + default => ['Content-Type: application/json'], }; } - return $headers; + $hasContentType = false; + foreach ($normalized as $header) { + if (stripos($header, 'content-type:') === 0) { + $hasContentType = true; + break; + } + } + + if (!$hasContentType) { + $normalized[] = $bodyType === 'form' + ? 'Content-Type: application/x-www-form-urlencoded' + : 'Content-Type: application/json'; + } + + return $normalized; } /** From 5132f4d3c4ec73895a9cff87e97baa3e2bdb1ea6 Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 18:39:03 +0800 Subject: [PATCH 2/7] Feature:add __CONTENT__PLAINTEXT__ for better experience in notice content(such as bark) --- .DS_Store | Bin 0 -> 8196 bytes xExtension-Webhook/README.md | 2 ++ xExtension-Webhook/configure.phtml | 4 ++++ xExtension-Webhook/extension.php | 31 +++++++++++++++++++++++++++++ xExtension-Webhook/i18n/en/ext.php | 1 + 5 files changed, 38 insertions(+) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2b17da0181b4f674f36a3089fe50e7d0cf3737cd GIT binary patch literal 8196 zcmeHM+fGwK6y1YFA)n8OlVO@_nIqevlQ&3S?f-)tUhiSNFBo7=2FR^U!5!1bYyleXpT z+*(~a&}az&yNbtp;2L#+V(QMeoSj>%P;lDn!8oXKC5EwZysxu6Y|GiXwH7YM!o|3e zjjK?Mjt*R1yNhXAo7=2FR$x&9*6xqw2$lPpoCo!Lg}ZULD31<{@({iFhqZ&Vd8G%g zmLm(`j^s2#{)v$HrEU9hzaZ>uUPXO3(WjC^MlzNQ>@CpSv3$e*vF9ALCoTCU{1AmtdgU z=CP;ty6ht_Z!ibH3ikblmaArLdD%9!z26Go~z^x0CHRbVUS zhr9#d5FU1=9WrSyeWp~5JUc<>kBlWVjfK4T{NdOb3O+a zfPCDz0{1LqjrD&gz5jpX<7UFFKvv-PC}39Gy*7R#X`#Q&E&OEAa{Q}%$r^l)6IRP{ vH0;6~EyrQ6|6z#hx_j4hc5W@+;P~$w0dXbJz;BxT{lC&=__CONTENT__ + + __CONTENT_PLAINTEXT__ + + __DATE__ diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 4cadcb7d..e4f13aee 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -50,6 +50,7 @@ final class WebhookExtension extends Minz_Extension { private const DEFAULT_METHOD = HTTP_METHOD::POST; private const DEFAULT_BODY_TYPE = BODY_TYPE::JSON; private const JSON_LOG_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + private const PLAINTEXT_MAX_LENGTH = 360; private bool $logsEnabled = false; @@ -257,6 +258,7 @@ private function buildReplacements(FreshRSS_Entry $entry): array { '__AUTHORS__' => $this->toSafeJsonStr($entry->authors(true)), '__TAGS__' => $this->toSafeJsonStr($entry->tags(true)), '__THUMBNAIL_URL__' => $this->toSafeJsonStr($this->getEntryThumbnail($entry)), + '__CONTENT_PLAINTEXT__' => $this->toSafeJsonStr($this->getPlainTextContent($entry)), ]; } @@ -597,6 +599,35 @@ private function getEntryThumbnail(FreshRSS_Entry $entry): string { return ''; } + private function getPlainTextContent(FreshRSS_Entry $entry): string { + $content = (string) $entry->content(); + if ($content === '') { + return ''; + } + + $text = strip_tags($content); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', $text) ?? ''; + $text = trim($text); + + if ($text === '') { + return ''; + } + + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + if (mb_strlen($text) > self::PLAINTEXT_MAX_LENGTH) { + return rtrim(mb_substr($text, 0, self::PLAINTEXT_MAX_LENGTH - 1)) . '…'; + } + return $text; + } + + if (strlen($text) > self::PLAINTEXT_MAX_LENGTH) { + return rtrim(substr($text, 0, self::PLAINTEXT_MAX_LENGTH - 1)) . '…'; + } + + return $text; + } + private function extractEnclosureUrl(mixed $enclosure): string { if (!is_object($enclosure)) { return ''; diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index 44c77f42..2819c3d8 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -33,6 +33,7 @@ '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_plaintext_description' => 'Plaintext excerpt with HTML removed (trimmed for notifications)', '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 " #")', From 1f6cd406b6f53596fd57d7984825b4c41c0619ff Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 19:18:48 +0800 Subject: [PATCH 3/7] Add advanced keyword matching modes for webhook rules --- xExtension-Webhook/README.md | 20 ++++ xExtension-Webhook/configure.phtml | 63 +++++++++-- xExtension-Webhook/extension.php | 166 +++++++++++++++++++++++++++-- xExtension-Webhook/i18n/en/ext.php | 11 ++ xExtension-Webhook/metadata.json | 2 +- 5 files changed, 247 insertions(+), 15 deletions(-) diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md index a82bbd74..7c5ed4d9 100644 --- a/xExtension-Webhook/README.md +++ b/xExtension-Webhook/README.md @@ -13,6 +13,7 @@ A powerful FreshRSS extension that automatically sends webhook notifications whe - **Configurable Formats**: Send data as JSON or form-encoded - **Template System**: Customizable webhook payloads with placeholders - **Update-aware Filters**: Ignore updates to existing entries to avoid duplicate notifications +- **Two Matching Modes**: Keep simple global matching or switch to field-specific advanced matching - **Comprehensive Logging**: Optional, privacy-aware logging for debugging and monitoring - **Test Functionality**: Built-in “Save and send test request” button to verify configuration instantly - **Thumbnail Placeholder**: Populate payloads with `__THUMBNAIL_URL__` for embeds and cards @@ -57,6 +58,11 @@ your-project-name - **Ignore Updated Entries**: Skip webhooks when an existing entry is updated - **Mark as Read**: Automatically mark matched entries as read after the webhook is sent +#### Matching Modes + +- **Basic Matching**: One keyword list, plus shared search scopes (title/feed/authors/content) +- **Advanced Matching**: Four dedicated keyword lists so each field has its own patterns + #### Webhook Settings - **Webhook URL**: Your webhook endpoint URL @@ -217,6 +223,20 @@ Enable logging in the extension settings to see detailed information about: ## 📝 Changelog +### Version 0.3.0 + +- Added matching modes: `Basic` (existing behavior) and `Advanced` (field-specific keyword lists) +- Added advanced keyword groups for title, feed, authors, and content +- Improved configuration model to support both matching strategies cleanly +- Updated configuration UI and documentation for the new matching workflow + +### Version 0.2.0 + +- Added `__CONTENT_PLAINTEXT__` placeholder (HTML stripped and length-limited for notifications) +- Added support for `__THUMBNAIL_URL__` placeholder +- Improved webhook request handling for headers, methods, and body encoding +- Added options for "Ignore updated entries" and explicit logging toggle + ### Version 0.1.1 - Initial release diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml index f1e4ec66..f25e2ad3 100644 --- a/xExtension-Webhook/configure.phtml +++ b/xExtension-Webhook/configure.phtml @@ -10,8 +10,26 @@ declare(strict_types=1);
⚙️ -
- +
+ + +
+ +
+ +
+ +
+
+ +

@@ -25,8 +43,8 @@ declare(strict_types=1);
-
- +
+
@@ -52,11 +70,42 @@ declare(strict_types=1);
+
-
-
- +

+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> /> diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index e4f13aee..753cd79e 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -32,7 +32,7 @@ enum HTTP_METHOD: string { * configured keyword filters. * * @author Lukas Melega, Ryahn - * @version 0.2.0 + * @version 0.3.0 * @since FreshRSS 1.20.0 */ final class WebhookExtension extends Minz_Extension { @@ -49,6 +49,8 @@ final class WebhookExtension extends Minz_Extension { }'; private const DEFAULT_METHOD = HTTP_METHOD::POST; private const DEFAULT_BODY_TYPE = BODY_TYPE::JSON; + private const MATCH_MODE_BASIC = 'basic'; + private const MATCH_MODE_ADVANCED = 'advanced'; private const JSON_LOG_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; private const PLAINTEXT_MAX_LENGTH = 360; @@ -125,13 +127,15 @@ public function processArticle($entry): FreshRSS_Entry { return $entry; } - $patterns = $config['keywords']; - if ($patterns === []) { + if (!$this->hasConfiguredKeywords($config)) { logWarning($this->logsEnabled, 'No keywords defined in Webhook extension settings.'); return $entry; } - $matchLog = $this->findMatchLog($entry, $patterns, $config); + $matchLog = $config['match_mode'] === self::MATCH_MODE_ADVANCED + ? $this->findMatchLogAdvanced($entry, $config) + : $this->findMatchLogBasic($entry, $config); + if ($matchLog === null) { return $entry; } @@ -146,12 +150,16 @@ public function processArticle($entry): FreshRSS_Entry { } /** - * Try to find a pattern that matches this entry. + * Try to find a pattern that matches this entry in basic mode. * - * @param array $patterns * @param array $config */ - private function findMatchLog(FreshRSS_Entry $entry, array $patterns, array $config): ?string { + private function findMatchLogBasic(FreshRSS_Entry $entry, array $config): ?string { + $patterns = $config['keywords']; + if ($patterns === []) { + return null; + } + $title = (string) $entry->title(); $link = (string) $entry->link(); $feed = $entry->feed(); @@ -197,6 +205,83 @@ private function findMatchLog(FreshRSS_Entry $entry, array $patterns, array $con return null; } + /** + * Try to find a pattern that matches this entry in advanced mode. + * + * @param array $config + */ + private function findMatchLogAdvanced(FreshRSS_Entry $entry, array $config): ?string { + $title = (string) $entry->title(); + $link = (string) $entry->link(); + $feed = $entry->feed(); + $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; + $authors = trim((string) $entry->authors(true)); + $content = (string) $entry->content(); + + $advancedLists = [ + 'title' => $config['keywords_title'] ?? [], + 'feed' => $config['keywords_feed'] ?? [], + 'authors' => $config['keywords_authors'] ?? [], + 'content' => $config['keywords_content'] ?? [], + ]; + + foreach ($advancedLists['title'] as $pattern) { + $normalizedPattern = $this->normalizePattern($pattern); + if ($normalizedPattern !== null && $this->isPatternFound($normalizedPattern, $title, $pattern)) { + return "Matched by title ⮕ pattern: {$pattern} ♦ title: {$title} ♦ link: {$link}"; + } + } + + foreach ($advancedLists['feed'] as $pattern) { + $normalizedPattern = $this->normalizePattern($pattern); + if ( + $normalizedPattern !== null + && $feedName !== '' + && $this->isPatternFound($normalizedPattern, $feedName, $pattern) + ) { + return "Matched by feed ⮕ pattern: {$pattern} ♦ feed: {$feedName} ♦ link: {$link}"; + } + } + + foreach ($advancedLists['authors'] as $pattern) { + $normalizedPattern = $this->normalizePattern($pattern); + if ( + $normalizedPattern !== null + && $authors !== '' + && $this->isPatternFound($normalizedPattern, $authors, $pattern) + ) { + return "Matched by authors ⮕ pattern: {$pattern} ♦ authors: {$authors} ♦ link: {$link}"; + } + } + + foreach ($advancedLists['content'] as $pattern) { + $normalizedPattern = $this->normalizePattern($pattern); + if ( + $normalizedPattern !== null + && $content !== '' + && $this->isPatternFound($normalizedPattern, $content, $pattern) + ) { + return "Matched by content ⮕ pattern: {$pattern} ♦ title: {$title} ♦ link: {$link}"; + } + } + + return null; + } + + /** + * @param array $config + */ + private function hasConfiguredKeywords(array $config): bool { + if (($config['match_mode'] ?? self::MATCH_MODE_BASIC) === self::MATCH_MODE_ADVANCED) { + return ($config['keywords_title'] ?? []) !== [] + || ($config['keywords_feed'] ?? []) !== [] + || ($config['keywords_authors'] ?? []) !== [] + || ($config['keywords_content'] ?? []) !== []; + } + + return ($config['keywords'] ?? []) !== []; + } + /** * Send article data via webhook. * @@ -305,6 +390,26 @@ private function ensureConfigurationDefaults(): void { $userConf->_attribute('keywords', []); $needsSave = true; } + if ($userConf->attributeString('match_mode') === null) { + $userConf->_attribute('match_mode', self::MATCH_MODE_BASIC); + $needsSave = true; + } + if ($userConf->attributeArray('keywords_title') === null) { + $userConf->_attribute('keywords_title', []); + $needsSave = true; + } + if ($userConf->attributeArray('keywords_feed') === null) { + $userConf->_attribute('keywords_feed', []); + $needsSave = true; + } + if ($userConf->attributeArray('keywords_authors') === null) { + $userConf->_attribute('keywords_authors', []); + $needsSave = true; + } + if ($userConf->attributeArray('keywords_content') === null) { + $userConf->_attribute('keywords_content', []); + $needsSave = true; + } $needsSave = $this->ensureBoolDefault($userConf, 'search_in_title', true) || $needsSave; $needsSave = $this->ensureBoolDefault($userConf, 'search_in_feed', false) || $needsSave; @@ -360,6 +465,11 @@ private function migrateLegacyConfiguration(FreshRSS_UserConfiguration $userConf $needsSave = false; $map = [ 'keywords' => ['type' => 'array'], + 'match_mode' => ['type' => 'string'], + 'keywords_title' => ['type' => 'array'], + 'keywords_feed' => ['type' => 'array'], + 'keywords_authors' => ['type' => 'array'], + 'keywords_content' => ['type' => 'array'], 'search_in_title' => ['type' => 'bool'], 'search_in_feed' => ['type' => 'bool'], 'search_in_authors' => ['type' => 'bool'], @@ -430,14 +540,27 @@ private function getUserConf(): ?FreshRSS_UserConfiguration { */ private function collectConfigurationFromRequest(): array { $keywords = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords')); + $keywordsTitle = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords_title')); + $keywordsFeed = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords_feed')); + $keywordsAuthors = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords_authors')); + $keywordsContent = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords_content')); $headers = $this->normalizeListInput(Minz_Request::paramTextToArray('webhook_headers')); $headers = $headers === [] ? self::DEFAULT_HEADERS : $headers; $methodValue = HTTP_METHOD::tryFrom(strtoupper(Minz_Request::paramString('webhook_method'))); $bodyTypeValue = BODY_TYPE::tryFrom(strtolower(Minz_Request::paramString('webhook_body_type'))); + $matchMode = Minz_Request::paramString('match_mode'); + if (!in_array($matchMode, [self::MATCH_MODE_BASIC, self::MATCH_MODE_ADVANCED], true)) { + $matchMode = self::MATCH_MODE_BASIC; + } return [ 'keywords' => $keywords, + 'match_mode' => $matchMode, + 'keywords_title' => $keywordsTitle, + 'keywords_feed' => $keywordsFeed, + 'keywords_authors' => $keywordsAuthors, + 'keywords_content' => $keywordsContent, 'search_in_title' => Minz_Request::paramBoolean('search_in_title'), 'search_in_feed' => Minz_Request::paramBoolean('search_in_feed'), 'search_in_authors' => Minz_Request::paramBoolean('search_in_authors'), @@ -470,6 +593,11 @@ private function getSnapshot(): ?array { return [ 'keywords' => $this->getArrayAttribute($userConf, 'keywords', []), + 'match_mode' => $this->normalizeMatchMode($userConf->attributeString('match_mode')), + 'keywords_title' => $this->getArrayAttribute($userConf, 'keywords_title', []), + 'keywords_feed' => $this->getArrayAttribute($userConf, 'keywords_feed', []), + 'keywords_authors' => $this->getArrayAttribute($userConf, 'keywords_authors', []), + 'keywords_content' => $this->getArrayAttribute($userConf, 'keywords_content', []), 'search_in_title' => $this->getBoolAttribute($userConf, 'search_in_title', true), 'search_in_feed' => $this->getBoolAttribute($userConf, 'search_in_feed', false), 'search_in_authors' => $this->getBoolAttribute($userConf, 'search_in_authors', false), @@ -493,6 +621,14 @@ private function normalizeBodyTypeValue(?string $bodyType): string { return (BODY_TYPE::tryFrom(strtolower((string) $bodyType)) ?? self::DEFAULT_BODY_TYPE)->value; } + private function normalizeMatchMode(?string $matchMode): string { + if ($matchMode === self::MATCH_MODE_ADVANCED) { + return self::MATCH_MODE_ADVANCED; + } + + return self::MATCH_MODE_BASIC; + } + /** * @return string[] */ @@ -666,6 +802,22 @@ public function getKeywordsData(): string { return implode(PHP_EOL, $keywords); } + public function getKeywordDataByField(string $field): string { + $config = $this->getSnapshot(); + return match ($field) { + 'title' => implode(PHP_EOL, $config['keywords_title'] ?? []), + 'feed' => implode(PHP_EOL, $config['keywords_feed'] ?? []), + 'authors' => implode(PHP_EOL, $config['keywords_authors'] ?? []), + 'content' => implode(PHP_EOL, $config['keywords_content'] ?? []), + default => '', + }; + } + + public function getMatchMode(): string { + $config = $this->getSnapshot(); + return $config['match_mode'] ?? self::MATCH_MODE_BASIC; + } + public function getWebhookHeaders(): string { $config = $this->getSnapshot(); $headers = $config['webhook_headers'] ?? self::DEFAULT_HEADERS; diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php index 2819c3d8..71cdec5b 100644 --- a/xExtension-Webhook/i18n/en/ext.php +++ b/xExtension-Webhook/i18n/en/ext.php @@ -9,6 +9,17 @@ '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', + 'match_mode' => 'Matching mode', + 'match_mode_basic' => 'Basic matching', + 'match_mode_advanced' => 'Advanced matching', + 'match_mode_description' => 'Basic mode uses one keyword list with shared scopes. Advanced mode uses dedicated keyword lists per field.', + 'basic_matching' => 'Basic matching', + 'advanced_matching' => 'Advanced matching (field-specific)', + 'advanced_matching_description' => 'These lists are used only when matching mode is set to Advanced.', + 'advanced_title' => 'Title keywords', + 'advanced_feed' => 'Feed keywords', + 'advanced_authors' => 'Author keywords', + 'advanced_content' => 'Content keywords', 'search_in' => 'Search in article\'s:', 'search_in_title' => 'title', 'search_in_feed' => 'feed', diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json index 6fd13bf4..0bd5fbd5 100644 --- a/xExtension-Webhook/metadata.json +++ b/xExtension-Webhook/metadata.json @@ -2,7 +2,7 @@ "name": "Webhook", "author": "Lukas Melega, Ryahn", "description": "Send custom webhook when new article appears (and matches custom criteria)", - "version": "0.2.0", + "version": "0.3.0", "entrypoint": "Webhook", "type": "system" } From 4904bf674ad0159e728d8e75866d33f19c6c4064 Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 19:39:17 +0800 Subject: [PATCH 4/7] Fix webhook extension typing and request validation --- xExtension-Webhook/extension.php | 401 ++++++++++++++++++++++--------- xExtension-Webhook/request.php | 78 +++--- 2 files changed, 330 insertions(+), 149 deletions(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 753cd79e..819ba298 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -35,9 +35,10 @@ enum HTTP_METHOD: string { * @version 0.3.0 * @since FreshRSS 1.20.0 */ -final class WebhookExtension extends Minz_Extension { - private const DEFAULT_URL = 'http://'; - private const DEFAULT_HEADERS = [ + final class WebhookExtension extends Minz_Extension { + private const DEFAULT_URL = 'http://'; + /** @var list */ + private const DEFAULT_HEADERS = [ 'User-Agent: FreshRSS', 'Content-Type: application/json', ]; @@ -63,6 +64,9 @@ public function init(): void { $this->registerHook('entry_before_insert', [$this, 'processArticle']); } + /** + * @throws Minz_PermissionDeniedException + */ #[\Override] public function handleConfigureAction(): void { $this->registerTranslates(); @@ -84,7 +88,7 @@ public function handleConfigureAction(): void { $userConf->save(); - $this->logsEnabled = (bool) ($config['enable_logging'] ?? false); + $this->logsEnabled = $config['enable_logging']; $loggable = $config; $loggable['webhook_body'] = '[redacted]'; @@ -94,24 +98,14 @@ public function handleConfigureAction(): void { ); if ($this->shouldSendTestRequest()) { - try { - $this->sendTestRequest($config); - } catch (Throwable $err) { - logError($this->logsEnabled, 'Test webhook request failed: ' . $err->getMessage()); - } + $this->sendTestRequest($config); } } /** * Process article and send webhook if patterns match. - * - * @param FreshRSS_Entry|mixed $entry */ - public function processArticle($entry): FreshRSS_Entry { - if (!$entry instanceof FreshRSS_Entry) { - return $entry; - } - + public function processArticle(FreshRSS_Entry $entry): FreshRSS_Entry { $config = $this->getSnapshot(); if ($config === null) { return $entry; @@ -152,7 +146,26 @@ public function processArticle($entry): FreshRSS_Entry { /** * Try to find a pattern that matches this entry in basic mode. * - * @param array $config + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config */ private function findMatchLogBasic(FreshRSS_Entry $entry, array $config): ?string { $patterns = $config['keywords']; @@ -160,12 +173,12 @@ private function findMatchLogBasic(FreshRSS_Entry $entry, array $config): ?strin return null; } - $title = (string) $entry->title(); - $link = (string) $entry->link(); + $title = $entry->title(); + $link = $entry->link(); $feed = $entry->feed(); - $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; - $authors = trim((string) $entry->authors(true)); - $content = (string) $entry->content(); + $feedName = $feed instanceof FreshRSS_Feed ? $feed->name() : ''; + $authors = trim($entry->authors(true)); + $content = $entry->content(); foreach ($patterns as $pattern) { $normalizedPattern = $this->normalizePattern($pattern); @@ -208,31 +221,43 @@ private function findMatchLogBasic(FreshRSS_Entry $entry, array $config): ?strin /** * Try to find a pattern that matches this entry in advanced mode. * - * @param array $config + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config */ private function findMatchLogAdvanced(FreshRSS_Entry $entry, array $config): ?string { - $title = (string) $entry->title(); - $link = (string) $entry->link(); + $title = $entry->title(); + $link = $entry->link(); $feed = $entry->feed(); - $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; - $authors = trim((string) $entry->authors(true)); - $content = (string) $entry->content(); - - $advancedLists = [ - 'title' => $config['keywords_title'] ?? [], - 'feed' => $config['keywords_feed'] ?? [], - 'authors' => $config['keywords_authors'] ?? [], - 'content' => $config['keywords_content'] ?? [], - ]; + $feedName = $feed instanceof FreshRSS_Feed ? $feed->name() : ''; + $authors = trim($entry->authors(true)); + $content = $entry->content(); - foreach ($advancedLists['title'] as $pattern) { + foreach ($config['keywords_title'] as $pattern) { $normalizedPattern = $this->normalizePattern($pattern); if ($normalizedPattern !== null && $this->isPatternFound($normalizedPattern, $title, $pattern)) { return "Matched by title ⮕ pattern: {$pattern} ♦ title: {$title} ♦ link: {$link}"; } } - foreach ($advancedLists['feed'] as $pattern) { + foreach ($config['keywords_feed'] as $pattern) { $normalizedPattern = $this->normalizePattern($pattern); if ( $normalizedPattern !== null @@ -243,7 +268,7 @@ private function findMatchLogAdvanced(FreshRSS_Entry $entry, array $config): ?st } } - foreach ($advancedLists['authors'] as $pattern) { + foreach ($config['keywords_authors'] as $pattern) { $normalizedPattern = $this->normalizePattern($pattern); if ( $normalizedPattern !== null @@ -254,7 +279,7 @@ private function findMatchLogAdvanced(FreshRSS_Entry $entry, array $config): ?st } } - foreach ($advancedLists['content'] as $pattern) { + foreach ($config['keywords_content'] as $pattern) { $normalizedPattern = $this->normalizePattern($pattern); if ( $normalizedPattern !== null @@ -269,40 +294,78 @@ private function findMatchLogAdvanced(FreshRSS_Entry $entry, array $config): ?st } /** - * @param array $config + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config */ private function hasConfiguredKeywords(array $config): bool { - if (($config['match_mode'] ?? self::MATCH_MODE_BASIC) === self::MATCH_MODE_ADVANCED) { - return ($config['keywords_title'] ?? []) !== [] - || ($config['keywords_feed'] ?? []) !== [] - || ($config['keywords_authors'] ?? []) !== [] - || ($config['keywords_content'] ?? []) !== []; + if ($config['match_mode'] === self::MATCH_MODE_ADVANCED) { + return $config['keywords_title'] !== [] + || $config['keywords_feed'] !== [] + || $config['keywords_authors'] !== [] + || $config['keywords_content'] !== []; } - return ($config['keywords'] ?? []) !== []; + return $config['keywords'] !== []; } /** * Send article data via webhook. * - * @param array $config + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config */ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog, array $config): void { try { - $bodyTemplate = (string) $config['webhook_body']; + $bodyTemplate = $config['webhook_body']; $replacements = $this->buildReplacements($entry); $body = str_replace(array_keys($replacements), array_values($replacements), $bodyTemplate); sendReq( - (string) $config['webhook_url'], - (string) $config['webhook_method'], - (string) $config['webhook_body_type'], + $config['webhook_url'], + $config['webhook_method'], + $config['webhook_body_type'], $body, $config['webhook_headers'], - (bool) $config['enable_logging'], + $config['enable_logging'], $additionalLog, ); - } catch (Throwable $err) { + } catch (RuntimeException|InvalidArgumentException|JsonException $err) { logError($this->logsEnabled, 'sendArticle error: ' . $err->getMessage()); } } @@ -310,16 +373,35 @@ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog, array /** * Send a manual test request using configuration data. * - * @param array $config + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config */ private function sendTestRequest(array $config): void { sendReq( - (string) $config['webhook_url'], - (string) $config['webhook_method'], - (string) $config['webhook_body_type'], - (string) $config['webhook_body'], + $config['webhook_url'], + $config['webhook_method'], + $config['webhook_body_type'], + $config['webhook_body'], $config['webhook_headers'], - (bool) $config['enable_logging'], + $config['enable_logging'], 'Test request from configuration', ); } @@ -331,7 +413,7 @@ private function sendTestRequest(array $config): void { */ private function buildReplacements(FreshRSS_Entry $entry): array { $feed = $entry->feed(); - $feedName = (is_object($feed) && method_exists($feed, 'name')) ? (string) $feed->name() : ''; + $feedName = $feed instanceof FreshRSS_Feed ? $feed->name() : ''; return [ '__TITLE__' => $this->toSafeJsonStr($entry->title()), @@ -360,7 +442,11 @@ private function toSafeJsonStr(mixed $value): string { } if (is_array($value)) { - $value = implode(', ', array_map(static fn ($item): string => (string) $item, $value)); + $items = []; + foreach ($value as $item) { + $items[] = $this->normalizeScalarToString($item); + } + $value = implode(', ', $items); } elseif (is_object($value)) { if (method_exists($value, '__toString')) { $value = (string) $value; @@ -369,12 +455,29 @@ private function toSafeJsonStr(mixed $value): string { } } - $string = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $string = preg_replace('/\s+/u', ' ', $string) ?? ''; + $string = html_entity_decode($this->normalizeScalarToString($value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $string = preg_replace('/\s+/u', ' ', $string) ?? ''; return addcslashes(trim($string), "\"\\"); } + private function normalizeScalarToString(mixed $value): string { + if ($value === null) { + return ''; + } + if (is_scalar($value)) { + return (string) $value; + } + if ($value instanceof DateTimeInterface) { + return $value->format(DateTimeInterface::ATOM); + } + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return ''; + } + /** * Ensure configuration defaults exist for the current user. */ @@ -453,12 +556,8 @@ private function ensureConfigurationDefaults(): void { * Attempt to migrate settings stored via the legacy system configuration. */ private function migrateLegacyConfiguration(FreshRSS_UserConfiguration $userConf): bool { - if (!method_exists($this, 'getSystemConfiguration')) { - return false; - } - $legacy = $this->getSystemConfiguration(); - if (!is_array($legacy) || $legacy === []) { + if ($legacy === []) { return false; } @@ -505,6 +604,7 @@ private function migrateLegacyConfiguration(FreshRSS_UserConfiguration $userConf } private function hasAttributeValue(FreshRSS_UserConfiguration $userConf, string $key, string $type): bool { + /** @var non-empty-string $key */ return match ($type) { 'array' => $userConf->attributeArray($key) !== null, 'bool' => $userConf->attributeBool($key) !== null, @@ -513,6 +613,7 @@ private function hasAttributeValue(FreshRSS_UserConfiguration $userConf, string } private function ensureBoolDefault(FreshRSS_UserConfiguration $userConf, string $key, bool $default): bool { + /** @var non-empty-string $key */ if ($userConf->attributeBool($key) === null) { $userConf->_attribute($key, $default); return true; @@ -536,7 +637,26 @@ private function getUserConf(): ?FreshRSS_UserConfiguration { /** * Collect configuration values from the current request payload. * - * @return array + * @return array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } */ private function collectConfigurationFromRequest(): array { $keywords = $this->normalizeListInput(Minz_Request::paramTextToArray('keywords')); @@ -583,7 +703,26 @@ private function shouldSendTestRequest(): bool { /** * Return the configuration snapshot for the current user. * - * @return array|null + * @return array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * }|null */ private function getSnapshot(): ?array { $userConf = $this->getUserConf(); @@ -614,13 +753,18 @@ private function getSnapshot(): ?array { } private function normalizeMethodValue(?string $method): string { - return (HTTP_METHOD::tryFrom(strtoupper((string) $method)) ?? self::DEFAULT_METHOD)->value; + $methodValue = $method ?? ''; + return (HTTP_METHOD::tryFrom(strtoupper($methodValue)) ?? self::DEFAULT_METHOD)->value; } private function normalizeBodyTypeValue(?string $bodyType): string { - return (BODY_TYPE::tryFrom(strtolower((string) $bodyType)) ?? self::DEFAULT_BODY_TYPE)->value; + $bodyTypeValue = $bodyType ?? ''; + return (BODY_TYPE::tryFrom(strtolower($bodyTypeValue)) ?? self::DEFAULT_BODY_TYPE)->value; } + /** + * @return 'basic'|'advanced' + */ private function normalizeMatchMode(?string $matchMode): string { if ($matchMode === self::MATCH_MODE_ADVANCED) { return self::MATCH_MODE_ADVANCED; @@ -630,17 +774,19 @@ private function normalizeMatchMode(?string $matchMode): string { } /** - * @return string[] + * @param non-empty-string $key + * @param list $default + * @return list */ private function getArrayAttribute(FreshRSS_UserConfiguration $userConf, string $key, array $default): array { $value = $userConf->attributeArray($key); - if (!is_array($value)) { + if ($value === null) { return $default; } $normalized = []; foreach ($value as $item) { - $trimmed = trim((string) $item); + $trimmed = trim($this->normalizeScalarToString($item)); if ($trimmed !== '') { $normalized[] = $trimmed; } @@ -649,11 +795,13 @@ private function getArrayAttribute(FreshRSS_UserConfiguration $userConf, string return $normalized; } + /** @param non-empty-string $key */ private function getBoolAttribute(FreshRSS_UserConfiguration $userConf, string $key, bool $default): bool { $value = $userConf->attributeBool($key); return $value ?? $default; } + /** @param non-empty-string $key */ private function getStringAttribute(FreshRSS_UserConfiguration $userConf, string $key, string $default): string { $value = $userConf->attributeString($key); return ($value === null || $value === '') ? $default : $value; @@ -663,7 +811,7 @@ private function getStringAttribute(FreshRSS_UserConfiguration $userConf, string * Normalize a list input (textarea) into trimmed values. * * @param array|null $values - * @return string[] + * @return list */ private function normalizeListInput(?array $values): array { if (!is_array($values)) { @@ -709,26 +857,20 @@ private function isPatternFound(string $pattern, string $text, string $fallback) } $fallbackNeedle = $fallback !== '' ? $fallback : $pattern; - return $fallbackNeedle !== '' && str_contains($text, $fallbackNeedle); + return str_contains($text, $fallbackNeedle); } private function getEntryThumbnail(FreshRSS_Entry $entry): string { - if (method_exists($entry, 'thumbnail')) { - $thumbnail = $entry->thumbnail(); - if (is_string($thumbnail) && $thumbnail !== '') { - return $thumbnail; - } + $thumbnail = $entry->thumbnail(); + if ($thumbnail !== null && $thumbnail['url'] !== '') { + return $thumbnail['url']; } - if (method_exists($entry, 'enclosures')) { - $enclosures = $entry->enclosures(); - if (is_array($enclosures)) { - foreach ($enclosures as $enclosure) { - $url = $this->extractEnclosureUrl($enclosure); - if ($url !== '') { - return $url; - } - } + $enclosures = $entry->enclosures(); + foreach ($enclosures as $enclosure) { + $url = $this->extractEnclosureUrl($enclosure); + if ($url !== '') { + return $url; } } @@ -736,7 +878,7 @@ private function getEntryThumbnail(FreshRSS_Entry $entry): string { } private function getPlainTextContent(FreshRSS_Entry $entry): string { - $content = (string) $entry->content(); + $content = $entry->content(); if ($content === '') { return ''; } @@ -765,10 +907,6 @@ private function getPlainTextContent(FreshRSS_Entry $entry): string { } private function extractEnclosureUrl(mixed $enclosure): string { - if (!is_object($enclosure)) { - return ''; - } - $url = $this->getEnclosureValue($enclosure, 'url'); if ($url === '') { return ''; @@ -782,15 +920,34 @@ private function extractEnclosureUrl(mixed $enclosure): string { return ''; } - private function getEnclosureValue(object $enclosure, string $name): string { - if (method_exists($enclosure, $name)) { - $value = $enclosure->{$name}(); - return is_string($value) ? $value : (string) $value; + private function getEnclosureValue(mixed $enclosure, string $name): string { + if (is_array($enclosure)) { + if (!array_key_exists($name, $enclosure)) { + return ''; + } + return $this->normalizeScalarToString($enclosure[$name]); + } + + if (!is_object($enclosure)) { + return ''; + } + + if ($name === 'url') { + if (method_exists($enclosure, 'get_link')) { + return $this->normalizeScalarToString($enclosure->get_link()); + } + if (method_exists($enclosure, 'link')) { + return $this->normalizeScalarToString($enclosure->link()); + } } - if (isset($enclosure->{$name})) { - $value = $enclosure->{$name}; - return is_string($value) ? $value : (string) $value; + if ($name === 'type') { + if (method_exists($enclosure, 'get_type')) { + return $this->normalizeScalarToString($enclosure->get_type()); + } + if (method_exists($enclosure, 'type')) { + return $this->normalizeScalarToString($enclosure->type()); + } } return ''; @@ -798,52 +955,62 @@ private function getEnclosureValue(object $enclosure, string $name): string { public function getKeywordsData(): string { $config = $this->getSnapshot(); - $keywords = $config['keywords'] ?? []; - return implode(PHP_EOL, $keywords); + if ($config === null) { + return ''; + } + + return implode(PHP_EOL, $config['keywords']); } public function getKeywordDataByField(string $field): string { $config = $this->getSnapshot(); + if ($config === null) { + return ''; + } + return match ($field) { - 'title' => implode(PHP_EOL, $config['keywords_title'] ?? []), - 'feed' => implode(PHP_EOL, $config['keywords_feed'] ?? []), - 'authors' => implode(PHP_EOL, $config['keywords_authors'] ?? []), - 'content' => implode(PHP_EOL, $config['keywords_content'] ?? []), + 'title' => implode(PHP_EOL, $config['keywords_title']), + 'feed' => implode(PHP_EOL, $config['keywords_feed']), + 'authors' => implode(PHP_EOL, $config['keywords_authors']), + 'content' => implode(PHP_EOL, $config['keywords_content']), default => '', }; } public function getMatchMode(): string { $config = $this->getSnapshot(); - return $config['match_mode'] ?? self::MATCH_MODE_BASIC; + return $config === null ? self::MATCH_MODE_BASIC : $config['match_mode']; } public function getWebhookHeaders(): string { $config = $this->getSnapshot(); - $headers = $config['webhook_headers'] ?? self::DEFAULT_HEADERS; - return implode(PHP_EOL, $headers); + if ($config === null) { + return implode(PHP_EOL, self::DEFAULT_HEADERS); + } + + return implode(PHP_EOL, $config['webhook_headers']); } public function getWebhookUrl(): string { $config = $this->getSnapshot(); - return $config['webhook_url'] ?? self::DEFAULT_URL; + return $config === null ? self::DEFAULT_URL : $config['webhook_url']; } public function getWebhookBody(): string { $config = $this->getSnapshot(); - return $config['webhook_body'] ?? self::DEFAULT_BODY_TEMPLATE; + return $config === null ? self::DEFAULT_BODY_TEMPLATE : $config['webhook_body']; } public function getWebhookBodyType(): string { $config = $this->getSnapshot(); - return $config['webhook_body_type'] ?? self::DEFAULT_BODY_TYPE->value; + return $config === null ? self::DEFAULT_BODY_TYPE->value : $config['webhook_body_type']; } } -function _LOG(bool $logEnabled, $data): void { +function _LOG(bool $logEnabled, mixed $data): void { logWarning($logEnabled, $data); } -function _LOG_ERR(bool $logEnabled, $data): void { +function _LOG_ERR(bool $logEnabled, mixed $data): void { logError($logEnabled, $data); } diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php index fc851289..977545f7 100644 --- a/xExtension-Webhook/request.php +++ b/xExtension-Webhook/request.php @@ -18,7 +18,6 @@ * * @throws InvalidArgumentException When invalid parameters are provided * @throws JsonException When JSON encoding/decoding fails - * @throws Minz_PermissionDeniedException * @throws RuntimeException When cURL operations fail * * @return void @@ -33,12 +32,13 @@ function sendReq( string $additionalLog = "", ): void { // Validate inputs - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { + if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) { throw new InvalidArgumentException("Invalid URL provided: {$url}"); } + $normalizedMethod = strtoupper($method); $allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD']; - if (!in_array(strtoupper($method), $allowedMethods, true)) { + if (!in_array($normalizedMethod, $allowedMethods, true)) { throw new InvalidArgumentException("Invalid HTTP method: {$method}"); } @@ -54,20 +54,20 @@ function sendReq( try { // Configure HTTP method - configureHttpMethod($ch, strtoupper($method)); + configureHttpMethod($ch, $normalizedMethod); // Process and set HTTP body - $processedBody = processHttpBody($body, $bodyType, $method, $logEnabled); - if ($processedBody !== null && $method !== 'GET') { - curl_setopt($ch, CURLOPT_POSTFIELDS, $processedBody); - } + $processedBody = processHttpBody($body, $bodyType, $normalizedMethod, $logEnabled); + if ($processedBody !== null && $normalizedMethod !== 'GET') { + curl_setopt($ch, CURLOPT_POSTFIELDS, $processedBody); + } // Configure headers $finalHeaders = configureHeaders($headers, $bodyType); curl_setopt($ch, CURLOPT_HTTPHEADER, $finalHeaders); // Log the request - logRequest($logEnabled, $additionalLog, $method, $url, $bodyType, $processedBody, $finalHeaders); + logRequest($logEnabled, $additionalLog, $normalizedMethod, $url, $bodyType, $processedBody, $finalHeaders); // Execute request executeRequest($ch, $logEnabled); @@ -86,7 +86,7 @@ function sendReq( * Sets the appropriate cURL options based on the HTTP method. * * @param CurlHandle $ch The cURL handle - * @param string $method HTTP method in uppercase + * @param non-empty-string $method HTTP method in uppercase * * @return void */ @@ -120,8 +120,6 @@ function configureHttpMethod(CurlHandle $ch, string $method): void { * * @throws JsonException When JSON processing fails * @throws InvalidArgumentException When unsupported body type is provided - * @throws Minz_PermissionDeniedException - * * @return string|null Processed body content or null if no body needed */ function processHttpBody(string $body, string $bodyType, string $method, bool $logEnabled): ?string { @@ -131,10 +129,11 @@ function processHttpBody(string $body, string $bodyType, string $method, bool $l try { $bodyObject = json_decode($body, true, 256, JSON_THROW_ON_ERROR); + $formData = is_array($bodyObject) ? $bodyObject : []; return match ($bodyType) { 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'form' => http_build_query($bodyObject ?? []), + 'form' => http_build_query($formData), default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}") }; } catch (JsonException $err) { @@ -152,7 +151,7 @@ function processHttpBody(string $body, string $bodyType, string $method, bool $l * @param string[] $headers Array of custom headers * @param string $bodyType Content type ('json' or 'form') * - * @return string[] Final array of headers to use + * @return list Final array of headers to use */ function configureHeaders(array $headers, string $bodyType): array { $normalized = []; @@ -201,8 +200,6 @@ function configureHeaders(array $headers, string $bodyType): array { * @param string|null $body Processed request body * @param string[] $headers Array of HTTP headers * - * @throws Minz_PermissionDeniedException - * * @return void */ function logRequest( @@ -219,7 +216,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}"); @@ -237,7 +234,6 @@ function logRequest( * @param bool $logEnabled Whether logging is enabled * * @throws RuntimeException When cURL execution fails - * @throws Minz_PermissionDeniedException * * @return void */ @@ -265,13 +261,15 @@ function executeRequest(CurlHandle $ch, bool $logEnabled): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log (will be converted to string) * - * @throws Minz_PermissionDeniedException - * * @return void */ -function logWarning(bool $logEnabled, $data): void { +function logWarning(bool $logEnabled, mixed $data): void { if ($logEnabled && class_exists('Minz_Log')) { - Minz_Log::warning("[WEBHOOK] " . $data); + try { + Minz_Log::warning('[WEBHOOK] ' . toLogString($data)); + } catch (Throwable) { + return; + } } } @@ -284,13 +282,15 @@ function logWarning(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log (will be converted to string) * - * @throws Minz_PermissionDeniedException - * * @return void */ -function logError(bool $logEnabled, $data): void { +function logError(bool $logEnabled, mixed $data): void { if ($logEnabled && class_exists('Minz_Log')) { - Minz_Log::error("[WEBHOOK]❌ " . $data); + try { + Minz_Log::error('[WEBHOOK]❌ ' . toLogString($data)); + } catch (Throwable) { + return; + } } } @@ -301,11 +301,9 @@ function logError(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * - * @throws Minz_PermissionDeniedException - * * @return void */ -function LOG_WARN(bool $logEnabled, $data): void { +function LOG_WARN(bool $logEnabled, mixed $data): void { logWarning($logEnabled, $data); } @@ -316,10 +314,26 @@ function LOG_WARN(bool $logEnabled, $data): void { * @param bool $logEnabled Whether logging is enabled * @param mixed $data Data to log * - * @throws Minz_PermissionDeniedException - * * @return void */ -function LOG_ERR(bool $logEnabled, $data): void { +function LOG_ERR(bool $logEnabled, mixed $data): void { logError($logEnabled, $data); } + +function toLogString(mixed $data): string { + if ($data === null) { + return ''; + } + if (is_scalar($data)) { + return (string) $data; + } + if ($data instanceof DateTimeInterface) { + return $data->format(DateTimeInterface::ATOM); + } + if (is_object($data) && method_exists($data, '__toString')) { + return (string) $data; + } + + $encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return is_string($encoded) ? $encoded : '[unserializable]'; +} From cd4b64fd30c101f94e7be592df570130bd8ef58a Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 19:49:57 +0800 Subject: [PATCH 5/7] Keep test webhook failures from blocking config save --- xExtension-Webhook/extension.php | 34 +++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 819ba298..5596c85a 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -98,7 +98,7 @@ public function handleConfigureAction(): void { ); if ($this->shouldSendTestRequest()) { - $this->sendTestRequest($config); + $this->sendTestRequestSafely($config); } } @@ -406,6 +406,38 @@ private function sendTestRequest(array $config): void { ); } + /** + * Keep configuration save path resilient even when test webhook fails. + * + * @param array{ + * keywords: list, + * match_mode: 'basic'|'advanced', + * keywords_title: list, + * keywords_feed: list, + * keywords_authors: list, + * keywords_content: list, + * search_in_title: bool, + * search_in_feed: bool, + * search_in_authors: bool, + * search_in_content: bool, + * mark_as_read: bool, + * ignore_updated: bool, + * webhook_headers: list, + * webhook_url: string, + * webhook_method: string, + * webhook_body: string, + * webhook_body_type: string, + * enable_logging: bool + * } $config + */ + private function sendTestRequestSafely(array $config): void { + try { + $this->sendTestRequest($config); + } catch (Throwable $err) { + logError($this->logsEnabled, 'Test webhook request failed: ' . $err->getMessage()); + } + } + /** * Build placeholder replacements for the configured template. * From 52a42edbda4bda9b606950d517da1d3e311db05b Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 20:13:03 +0800 Subject: [PATCH 6/7] complement author --- xExtension-Webhook/extension.php | 2 +- xExtension-Webhook/metadata.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 5596c85a..7c3be6cc 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -31,7 +31,7 @@ enum HTTP_METHOD: string { * Sends configurable webhook requests whenever new entries match the * configured keyword filters. * - * @author Lukas Melega, Ryahn + * @author Lukas Melega, Ryahn, onlymykazari * @version 0.3.0 * @since FreshRSS 1.20.0 */ diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json index 0bd5fbd5..78fa0172 100644 --- a/xExtension-Webhook/metadata.json +++ b/xExtension-Webhook/metadata.json @@ -1,6 +1,6 @@ { "name": "Webhook", - "author": "Lukas Melega, Ryahn", + "author": "Lukas Melega, Ryahn, onlymykazari", "description": "Send custom webhook when new article appears (and matches custom criteria)", "version": "0.3.0", "entrypoint": "Webhook", From 1590f09b2e182f544d91f743906a689cea41298b Mon Sep 17 00:00:00 2001 From: Jp302109 <15944448389@163.com> Date: Sat, 7 Feb 2026 21:51:19 +0800 Subject: [PATCH 7/7] Narrow test-request catch to real exception types --- xExtension-Webhook/extension.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php index 7c3be6cc..4b87118d 100644 --- a/xExtension-Webhook/extension.php +++ b/xExtension-Webhook/extension.php @@ -373,6 +373,10 @@ private function sendArticle(FreshRSS_Entry $entry, string $additionalLog, array /** * Send a manual test request using configuration data. * + * @throws InvalidArgumentException + * @throws JsonException + * @throws RuntimeException + * * @param array{ * keywords: list, * match_mode: 'basic'|'advanced', @@ -433,7 +437,7 @@ private function sendTestRequest(array $config): void { private function sendTestRequestSafely(array $config): void { try { $this->sendTestRequest($config); - } catch (Throwable $err) { + } catch (RuntimeException|InvalidArgumentException|JsonException $err) { logError($this->logsEnabled, 'Test webhook request failed: ' . $err->getMessage()); } }