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..36a325eb --- /dev/null +++ b/xExtension-Webhook/README.md @@ -0,0 +1,71 @@ +# FreshRSS Webhook + +A FreshRSS extension for sending custom webhooks when new article appears (and matches custom criteria) + +## Installation / Usage + +Please follow official README: https://github.com/FreshRSS/Extensions?tab=readme-ov-file + +## Documentation + +You can define keywords to be used for matching new incoming article. +When article contains at least one defined keyword, the webhook will be sent. + +Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined. These must be able to be evaluated using the PHP function `preg_match`. + +Examples: + +```text +some keyword +important +/\p{Latin}/i +``` + +In addition, you can choose whether the matched articles will not be inserted into the database or whether they will be inserted into the database but marked as read (default). + +## How it works + +``` +┌──────────────┐ ┌────────────────────────────────────┐ ┌───────┐ +│ │ │ FreshRSS │ │ │ +│ │ │ │ │ some │ +│ INTERNET │ │ ┌────────┐ ┌─────↓─────┐ │ │ │ +│ │ │ │FreshRSS│ │• Webhook •│ │ │service│ +│ │ │ │ core │ │ extension │ │ │ │ +└────┬─────────┘ └──┴──┬─────┴─────────┴─────┬─────┴──┘ └─────┬─┘ + │ │ │ │ + │ checks RSS │ │ │ + │ for new articles │ │ │ + │◄─────────────────────────┤ │ │ + │ │ │ if some new article │ + ├─────────────────────────►│ │ matches custom criteria │ + │ new articles ├────────────────────►│ │ + │ │ new articles ├────────────────────────►│ + │ │ │ HTTP request │ + │ │ │ │ + │ checks RSS │ │ │ + │ or new articles │ │ │ + │◄─────────────────────────┤ │ │ + │ │ │ if no new article │ + ├─────────────────────────►│ │ matches custom criteria │ + │ new articles ├────────────────────►│ no request will be sent │ + │ │ new articles │ │ + │ │ │ │ + │ │ │ │ + ▼ ▼ ▼ ▼ +``` + +- for every new article that matches custom criteria new HTTP request will be sent + +- see also discussion: https://github.com/FreshRSS/FreshRSS/discussions/6480 + +## ⚠️ Limitations + +- currently only GET, POST and PUT methods are supported +- there is no validation for configuration +- it's not fully tested and translated yet + +## Special Thanks + +- inspired by extension [**FilterTitle**](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-FilterTitle) +by [@cn-tools](https://github.com/cn-tools) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 00000000..27d7e000 --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,220 @@ + + + Webhooks allow external services to be notified when certain events happen. + When the specified events happen, we'll send a HTTP request (usually POST) to the URL you provide. + +
+ diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 00000000..aa3b45f5 --- /dev/null +++ b/xExtension-Webhook/extension.php @@ -0,0 +1,246 @@ +"; + + /** * @var string[] $webhook_headers as array of strings */ + public array $webhook_headers = ["User-Agent: FreshRSS", "Content-Type: application/x-www-form-urlencoded"]; + public string $webhook_body = '{ + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "created": "__DATE_TIMESTAMP__" +}'; + + #[\Override] + public function init(): void { + $this->registerTranslates(); + $this->registerHook("entry_before_insert", [$this, "processArticle"]); + } + + public function handleConfigureAction(): void { + $this->registerTranslates(); + + if (Minz_Request::isPost()) { + $conf = [ + "keywords" => array_filter(Minz_Request::paramTextToArray("keywords", [])), + "search_in_title" => Minz_Request::paramString("search_in_title"), + "search_in_feed" => Minz_Request::paramString("search_in_feed"), + "search_in_authors" => Minz_Request::paramString("search_in_authors"), + "search_in_content" => Minz_Request::paramString("search_in_content"), + "mark_as_read" => (bool) Minz_Request::paramString("mark_as_read"), + "ignore_updated" => (bool) Minz_Request::paramString("ignore_updated"), + + "webhook_url" => Minz_Request::paramString("webhook_url"), + "webhook_method" => Minz_Request::paramString("webhook_method"), + "webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers", [])), + "webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")), + "webhook_body_type" => Minz_Request::paramString("webhook_body_type"), + "enable_logging" => (bool) Minz_Request::paramString("enable_logging"), + ]; + $this->setSystemConfiguration($conf); + $this->$logsEnabled = $conf["enable_logging"]; + + _LOG($this->$logsEnabled, "saved config: ✅ " . json_encode($conf)); + + try { + if (Minz_Request::paramString("test_request")) { + sendReq( + $conf["webhook_url"], + $conf["webhook_method"], + $conf["webhook_body_type"], + $conf["webhook_body"], + $conf["webhook_headers"], + $conf["enable_logging"], + ); + } + } catch (Throwable $err) { + _LOG_ERR($this->$logsEnabled, "Error when sending TEST webhook. " . $err); + } + } + } + + public function processArticle($entry) { + if (!is_object($entry)) { + return; + } + if ($this->getSystemConfigurationValue("ignore_updated") && $entry->isUpdated()) { + _LOG(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + return $entry; + } + + $searchInTitle = $this->getSystemConfigurationValue("search_in_title") ?? false; + $searchInFeed = $this->getSystemConfigurationValue("search_in_feed") ?? false; + $searchInAuthors = $this->getSystemConfigurationValue("search_in_authors") ?? false; + $searchInContent = $this->getSystemConfigurationValue("search_in_content") ?? false; + + $patterns = $this->getSystemConfigurationValue("keywords") ?? []; + $markAsRead = $this->getSystemConfigurationValue("mark_as_read") ?? false; + $logsEnabled = (bool) $this->getSystemConfigurationValue("enable_logging") ?? false; + $this->$logsEnabled = (bool) $this->getSystemConfigurationValue("enable_logging") ?? false; + + //-- do check keywords: --------------------------- + if (!is_array($patterns)) { + _LOG_ERR($logsEnabled, "❗️ No keywords defined in Webhook extension settings."); + return; + } + + $title = "❗️NOT INITIALIZED"; + $link = "❗️NOT INITIALIZED"; + $additionalLog = ""; + + try { + $title = $entry->title(); + $link = $entry->link(); + foreach ($patterns as $pattern) { + if ($searchInTitle && self::isPatternFound("/{$pattern}/", $title)) { + _LOG($logsEnabled, "matched item by title ✔️ \"{$title}\" ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ title \"{$title}\" ❖ link: {$link}"; + break; + } + if ($searchInFeed && (is_object($entry->feed()) && self::isPatternFound("/{$pattern}/", $entry->feed()->name()))) { + _LOG($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}"; + break; + } + if ($searchInAuthors && self::isPatternFound("/{$pattern}/", $entry->authors(true))) { + _LOG($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}"; + break; + } + if ($searchInContent && self::isPatternFound("/{$pattern}/", $entry->content())) { + _LOG($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"; + break; + } + } + + if ($markAsRead) { + $entry->_isRead($markAsRead); + } + + $this->sendArticle($entry, $additionalLog); + + } catch (Throwable $err) { + _LOG_ERR($logsEnabled, "Error during sending article ({$link} ❖ \"{$title}\") ERROR: {$err}"); + } + + return $entry; + } + + private function sendArticle($entry, string $additionalLog = ""): void { + try { + $webhookBodyType = $this->getSystemConfigurationValue("webhook_body_type"); + $headers = $this->getSystemConfigurationValue("webhook_headers"); + $bodyStr = $this->getSystemConfigurationValue("webhook_body"); + + $bodyStr = str_replace("__TITLE__", self::toSafeJsonStr($entry->title()), $bodyStr); + $bodyStr = str_replace("__FEED__", self::toSafeJsonStr($entry->feed()->name()), $bodyStr); + $bodyStr = str_replace("__URL__", self::toSafeJsonStr($entry->link()), $bodyStr); + $bodyStr = str_replace("__CONTENT__", self::toSafeJsonStr($entry->content()), $bodyStr); + $bodyStr = str_replace("__DATE__", self::toSafeJsonStr($entry->date()), $bodyStr); + $bodyStr = str_replace("__DATE_TIMESTAMP__", self::toSafeJsonStr($entry->date(true)), $bodyStr); + $bodyStr = str_replace("__AUTHORS__", self::toSafeJsonStr($entry->authors(true)), $bodyStr); + $bodyStr = str_replace("__TAGS__", self::toSafeJsonStr($entry->tags(true)), $bodyStr); + + sendReq( + $this->getSystemConfigurationValue("webhook_url"), + $this->getSystemConfigurationValue("webhook_method"), + $this->getSystemConfigurationValue("webhook_body_type"), + $bodyStr, + $this->getSystemConfigurationValue("webhook_headers"), + (bool) $this->getSystemConfigurationValue("enable_logging"), + $additionalLog, + ); + } catch (Throwable $err) { + _LOG_ERR($this->$logsEnabled, "ERROR in sendArticle: {$err}"); + } + } + + private function toSafeJsonStr(string|int $str): string { + $output = $str; + if (is_numeric($str)) { + $output = "{$str}"; + } else { + $output = str_replace("/\"/", "", html_entity_decode($output)); + } + return $output; + } + + private function isPatternFound(string $pattern, string $text): bool { + if (empty($text) || empty($pattern)) { + return false; + } + try { + if (1 === preg_match($pattern, $text)) { + return true; + } elseif (strpos($text, $pattern) !== false) { + return true; + } + return false; + } catch (Throwable $err) { + _LOG_ERR($this->$logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err}"); + return false; + } + } + + public function getKeywordsData() { + return implode(PHP_EOL, $this->getSystemConfigurationValue("keywords") ?? []); + } + + public function getWebhookHeaders() { + return implode( + PHP_EOL, + $this->getSystemConfigurationValue("webhook_headers") ?? ($this->webhook_headers ?? []), + ); + } + + public function getWebhookUrl() { + return $this->getSystemConfigurationValue("webhook_url") ?? $this->webhook_url; + } + + public function getWebhookBody() { + $body = $this->getSystemConfigurationValue("webhook_body"); + return !$body || $body === "" ? $this->webhook_body : $body; + } + + public function getWebhookBodyType() { + return $this->getSystemConfigurationValue("webhook_body_type") ?? $this->webhook_body_type; + } +} + +function _LOG(bool $logEnabled, $data): void { + if ($logEnabled) { + Minz_Log::warning("[WEBHOOK] " . $data); + } +} + +function _LOG_ERR(bool $logEnabled, $data): void { + if ($logEnabled) { + Minz_Log::error("[WEBHOOK] ❌ " . $data); + } +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 00000000..c618e490 --- /dev/null +++ b/xExtension-Webhook/i18n/en/ext.php @@ -0,0 +1,11 @@ + 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', + ), +); diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json new file mode 100644 index 00000000..87e6f9c2 --- /dev/null +++ b/xExtension-Webhook/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "Webhook", + "author": "Lukas Melega", + "description": "Send custom webhook when new article appears (and matches custom criteria)", + "version": "0.1.0", + "entrypoint": "Webhook", + "type": "system" +} diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php new file mode 100644 index 00000000..bb4da92f --- /dev/null +++ b/xExtension-Webhook/request.php @@ -0,0 +1,110 @@ + sendReq ⏩ ( {$method}: " . $url . ", " . $bodyType . ", " . json_encode($body)); + + /** @var CurlHandle $ch */ + $ch = curl_init($url); + try { + // ----------------------[ HTTP Method: ]----------------------------------- + + if ($method === "POST") { + curl_setopt($ch, CURLOPT_POST, true); + } + if ($method === "PUT") { + curl_setopt($ch, CURLOPT_PUT, true); + } + if ($method === "GET") { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET"); + } + if ($method === "DELETE") { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE"); + } + + // ----------------------[ HTTP Body: ]----------------------------------- + + $bodyObject = null; + $bodyToSend = null; + try { + // $bodyObject = json_decode(json_encode($body ?? ""), true, 64, JSON_THROW_ON_ERROR); + $bodyObject = json_decode(($body ?? "{}"), true, 256, JSON_THROW_ON_ERROR); + + // LOG_WARN($logEnabled, "bodyObject: " . json_encode($bodyObject)); + + if ($bodyType === "json") { + $bodyToSend = json_encode($bodyObject); + // LOG_WARN($logEnabled, "> json_encode ⏩: {$bodyToSend}"); + } + if ($bodyType === "form") { + $bodyToSend = http_build_query($bodyObject); + // LOG_WARN($logEnabled, "> http_build_query ⏩: " . $body); + } + + if (!empty($body) && $method !== "GET") { + curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyToSend); + } + + } catch (Throwable $err) { + LOG_ERR($logEnabled, "ERROR during parsing HTTP Body, ERROR: {$err} | Body: {$body}"); + LOG_ERR($logEnabled, "ERROR during parsing HTTP Body, ERROR: " . json_encode($err) . "| Body: {$body}"); + throw $err; + } + + // LOG_WARN($logEnabled, "> sendReq ⏩ {$method}: {$url} ♦♦ {$bodyType} ♦♦ {$bodyToSend}"); + + // ----------------------[ HTTP Headers: ]----------------------------------- + + if (empty($headers)) { + if ($bodyType === "form") { + array_push($headers, "Content-Type: application/x-www-form-urlencoded"); + } + if ($bodyType === "json") { + array_push($headers, "Content-Type: application/json"); + } + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + LOG_WARN($logEnabled, "{$additionalLog} ♦♦ sendReq ⏩ {$method}: " . urldecode($url) ." ♦♦ {$bodyType} ♦♦ " . str_replace("\/", "/", $bodyToSend) . " ♦♦ " . json_encode($headers)); + + // ----------------------[ 🚀 SEND Request! ]----------------------------------- + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $response = curl_exec($ch); + $info = curl_getinfo($ch); + + // ----------------------[ Check for errors: ]----------------------------------- + + if (curl_errno($ch)) { + $error = curl_error($ch); + LOG_ERR($logEnabled, "< ERROR: " . $error); + } else { + LOG_WARN($logEnabled, "< Response ✅ (" . $info["http_code"] . ") response:" . $response); + } + + } catch (Throwable $err) { + LOG_ERR($logEnabled, "< ERROR in sendReq: " . $err . " ♦♦ body: {$body} ♦♦"); + } finally { + // Close the cURL session + curl_close($ch); + } +} + +function LOG_WARN(bool $logEnabled, $data): void { + if ($logEnabled) { + Minz_Log::warning("[WEBHOOK] " . $data); + } +} + +function LOG_ERR(bool $logEnabled, $data): void { + if ($logEnabled) { + Minz_Log::error("[WEBHOOK]❌ " . $data); + } +}