feat(extensions): add Webhook extension for automated RSS notifications - Implements webhook notifications for RSS entries matching keywords - Supports pattern matching in titles, feeds, authors, and content - Configurable HTTP methods and request formats - Comprehensive error handling and logging - Resolves #1513
This commit is contained in:
parent
8f371bba3b
commit
9b20dc5f27
7 changed files with 1072 additions and 0 deletions
433
xExtension-Webhook/extension.php
Normal file
433
xExtension-Webhook/extension.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
include __DIR__ . "/request.php";
|
||||
|
||||
/**
|
||||
* Enumeration for HTTP request body types
|
||||
*
|
||||
* Defines the supported content types for webhook request bodies.
|
||||
*/
|
||||
enum BODY_TYPE: string {
|
||||
case JSON = "json";
|
||||
case FORM = "form";
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration for HTTP methods
|
||||
*
|
||||
* Defines the supported HTTP methods for webhook requests.
|
||||
*/
|
||||
enum HTTP_METHOD: string {
|
||||
case GET = "GET";
|
||||
case POST = "POST";
|
||||
case PUT = "PUT";
|
||||
case DELETE = "DELETE";
|
||||
case PATCH = "PATCH";
|
||||
case OPTIONS = "OPTIONS";
|
||||
case HEAD = "HEAD";
|
||||
}
|
||||
|
||||
/**
|
||||
* FreshRSS Webhook Extension
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @author Lukas Melega, Ryahn
|
||||
* @version 0.1.1
|
||||
* @since FreshRSS 1.20.0
|
||||
*/
|
||||
class WebhookExtension extends Minz_Extension {
|
||||
/**
|
||||
* Whether logging is enabled for this extension
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public bool $logsEnabled = false;
|
||||
|
||||
/**
|
||||
* Default HTTP method for webhook requests
|
||||
*
|
||||
* @var HTTP_METHOD
|
||||
*/
|
||||
public HTTP_METHOD $webhook_method = HTTP_METHOD::POST;
|
||||
|
||||
/**
|
||||
* 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://<WRITE YOUR URL HERE>";
|
||||
|
||||
/**
|
||||
* Default HTTP headers for webhook requests
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public array $webhook_headers = ["User-Agent: FreshRSS", "Content-Type: application/x-www-form-urlencoded"];
|
||||
|
||||
/**
|
||||
* Default webhook request body template
|
||||
*
|
||||
* Supports placeholders like __TITLE__, __FEED__, __URL__, etc.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public string $webhook_body = '{
|
||||
"title": "__TITLE__",
|
||||
"feed": "__FEED__",
|
||||
"url": "__URL__",
|
||||
"created": "__DATE_TIMESTAMP__"
|
||||
}';
|
||||
|
||||
/**
|
||||
* Initialize the extension
|
||||
*
|
||||
* Registers translation files and hooks into FreshRSS entry processing.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[\Override]
|
||||
public function init(): void {
|
||||
$this->registerTranslates();
|
||||
$this->registerHook("entry_before_insert", [$this, "processArticle"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle configuration form submission
|
||||
*
|
||||
* Processes configuration form data, saves settings, and optionally
|
||||
* sends a test webhook request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handleConfigureAction(): void {
|
||||
$this->registerTranslates();
|
||||
|
||||
if (Minz_Request::isPost()) {
|
||||
$conf = [
|
||||
"keywords" => array_filter(Minz_Request::paramTextToArray("keywords")),
|
||||
"search_in_title" => Minz_Request::paramString("search_in_title"),
|
||||
"search_in_feed" => Minz_Request::paramString("search_in_feed"),
|
||||
"search_in_authors" => Minz_Request::paramString("search_in_authors"),
|
||||
"search_in_content" => Minz_Request::paramString("search_in_content"),
|
||||
"mark_as_read" => (bool) Minz_Request::paramString("mark_as_read"),
|
||||
"ignore_updated" => (bool) Minz_Request::paramString("ignore_updated"),
|
||||
|
||||
"webhook_url" => Minz_Request::paramString("webhook_url"),
|
||||
"webhook_method" => Minz_Request::paramString("webhook_method"),
|
||||
"webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers")),
|
||||
"webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")),
|
||||
"webhook_body_type" => Minz_Request::paramString("webhook_body_type"),
|
||||
"enable_logging" => (bool) Minz_Request::paramString("enable_logging"),
|
||||
];
|
||||
$this->setSystemConfiguration($conf);
|
||||
$logsEnabled = $conf["enable_logging"];
|
||||
$this->logsEnabled = $conf["enable_logging"];
|
||||
|
||||
logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf));
|
||||
|
||||
if (Minz_Request::paramString("test_request")) {
|
||||
try {
|
||||
sendReq(
|
||||
$conf["webhook_url"],
|
||||
$conf["webhook_method"],
|
||||
$conf["webhook_body_type"],
|
||||
$conf["webhook_body"],
|
||||
$conf["webhook_headers"],
|
||||
$conf["enable_logging"],
|
||||
"Test request from configuration"
|
||||
);
|
||||
} catch (Throwable $err) {
|
||||
logError($logsEnabled, "Test request failed: {$err->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process article and send webhook if patterns match
|
||||
*
|
||||
* Analyzes RSS entries against configured keyword patterns and sends
|
||||
* webhook notifications for matching entries. Supports pattern matching
|
||||
* in titles, feeds, authors, and content.
|
||||
*
|
||||
* @param FreshRSS_Entry $entry The RSS entry to process
|
||||
*
|
||||
* @return FreshRSS_Entry The processed entry (potentially marked as read)
|
||||
*/
|
||||
public function processArticle($entry): FreshRSS_Entry {
|
||||
if (!is_object($entry)) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
if ((bool)$this->getSystemConfigurationValue("ignore_updated") && $entry->isUpdated()) {
|
||||
logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title());
|
||||
return $entry;
|
||||
}
|
||||
|
||||
$searchInTitle = (bool)($this->getSystemConfigurationValue("search_in_title") ?? false);
|
||||
$searchInFeed = (bool)($this->getSystemConfigurationValue("search_in_feed") ?? false);
|
||||
$searchInAuthors = (bool)($this->getSystemConfigurationValue("search_in_authors") ?? false);
|
||||
$searchInContent = (bool)($this->getSystemConfigurationValue("search_in_content") ?? false);
|
||||
|
||||
$patterns = $this->getSystemConfigurationValue("keywords") ?? [];
|
||||
$markAsRead = (bool)($this->getSystemConfigurationValue("mark_as_read") ?? false);
|
||||
$logsEnabled = (bool)($this->getSystemConfigurationValue("enable_logging") ?? false);
|
||||
$this->logsEnabled = $logsEnabled;
|
||||
|
||||
// Validate patterns
|
||||
if (!is_array($patterns) || empty($patterns)) {
|
||||
logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings.");
|
||||
return $entry;
|
||||
}
|
||||
|
||||
$title = "❗️NOT INITIALIZED";
|
||||
$link = "❗️NOT INITIALIZED";
|
||||
$additionalLog = "";
|
||||
|
||||
try {
|
||||
$title = $entry->title();
|
||||
$link = $entry->link();
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$matchFound = false;
|
||||
|
||||
if ($searchInTitle && $this->isPatternFound("/{$pattern}/", $title)) {
|
||||
logWarning($logsEnabled, "matched item by title ✔️ \"{$title}\" ❖ link: {$link}");
|
||||
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ title \"{$title}\" ❖ link: {$link}";
|
||||
$matchFound = true;
|
||||
}
|
||||
|
||||
if (!$matchFound && $searchInFeed && is_object($entry->feed()) && $this->isPatternFound("/{$pattern}/", $entry->feed()->name())) {
|
||||
logWarning($logsEnabled, "matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}");
|
||||
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}";
|
||||
$matchFound = true;
|
||||
}
|
||||
|
||||
if (!$matchFound && $searchInAuthors && $this->isPatternFound("/{$pattern}/", $entry->authors(true))) {
|
||||
logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}");
|
||||
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}";
|
||||
$matchFound = true;
|
||||
}
|
||||
|
||||
if (!$matchFound && $searchInContent && $this->isPatternFound("/{$pattern}/", $entry->content())) {
|
||||
logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}");
|
||||
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}";
|
||||
$matchFound = true;
|
||||
}
|
||||
|
||||
if ($matchFound) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($markAsRead) {
|
||||
$entry->_isRead($markAsRead);
|
||||
}
|
||||
|
||||
// Only send webhook if a pattern was matched
|
||||
if (!empty($additionalLog)) {
|
||||
$this->sendArticle($entry, $additionalLog);
|
||||
}
|
||||
|
||||
} catch (Throwable $err) {
|
||||
logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}");
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send article data via webhook
|
||||
*
|
||||
* Prepares and sends webhook notification with article data.
|
||||
* Replaces template placeholders with actual entry values.
|
||||
*
|
||||
* @param FreshRSS_Entry $entry The RSS entry to send
|
||||
* @param string $additionalLog Additional context for logging
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void {
|
||||
try {
|
||||
$bodyStr = (string)$this->getSystemConfigurationValue("webhook_body");
|
||||
|
||||
// Replace placeholders with actual values
|
||||
$replacements = [
|
||||
"__TITLE__" => $this->toSafeJsonStr($entry->title()),
|
||||
"__FEED__" => $this->toSafeJsonStr($entry->feed()->name()),
|
||||
"__URL__" => $this->toSafeJsonStr($entry->link()),
|
||||
"__CONTENT__" => $this->toSafeJsonStr($entry->content()),
|
||||
"__DATE__" => $this->toSafeJsonStr($entry->date()),
|
||||
"__DATE_TIMESTAMP__" => $this->toSafeJsonStr($entry->date(true)),
|
||||
"__AUTHORS__" => $this->toSafeJsonStr($entry->authors(true)),
|
||||
"__TAGS__" => $this->toSafeJsonStr($entry->tags(true)),
|
||||
];
|
||||
|
||||
$bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr);
|
||||
|
||||
sendReq(
|
||||
(string)$this->getSystemConfigurationValue("webhook_url"),
|
||||
(string)$this->getSystemConfigurationValue("webhook_method"),
|
||||
(string)$this->getSystemConfigurationValue("webhook_body_type"),
|
||||
$bodyStr,
|
||||
(array)$this->getSystemConfigurationValue("webhook_headers"),
|
||||
(bool)$this->getSystemConfigurationValue("enable_logging"),
|
||||
$additionalLog,
|
||||
);
|
||||
} catch (Throwable $err) {
|
||||
logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string/int to safe JSON string
|
||||
*
|
||||
* Sanitizes input values for safe inclusion in JSON payloads
|
||||
* by removing quotes and decoding HTML entities.
|
||||
*
|
||||
* @param string|int $str Input value to sanitize
|
||||
*
|
||||
* @return string Sanitized string safe for JSON inclusion
|
||||
*/
|
||||
private function toSafeJsonStr(string|int $str): string {
|
||||
if (is_numeric($str)) {
|
||||
return (string)$str;
|
||||
}
|
||||
|
||||
// Remove quotes and decode HTML entities
|
||||
return str_replace('"', '', html_entity_decode((string)$str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pattern is found in text
|
||||
*
|
||||
* Attempts regex matching first, then falls back to simple string search.
|
||||
* Handles regex errors gracefully and logs issues.
|
||||
*
|
||||
* @param string $pattern Search pattern (may include regex delimiters)
|
||||
* @param string $text Text to search in
|
||||
*
|
||||
* @return bool True if pattern is found, false otherwise
|
||||
*/
|
||||
private function isPatternFound(string $pattern, string $text): bool {
|
||||
if (empty($text) || empty($pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try regex match first
|
||||
if (preg_match($pattern, $text) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to string search (remove regex delimiters)
|
||||
$cleanPattern = trim($pattern, '/');
|
||||
return str_contains($text, $cleanPattern);
|
||||
|
||||
} catch (Throwable $err) {
|
||||
logError($this->logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err->getMessage()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keywords configuration as formatted string
|
||||
*
|
||||
* Returns the configured keywords as a newline-separated string
|
||||
* for display in the configuration form.
|
||||
*
|
||||
* @return string Keywords separated by newlines
|
||||
*/
|
||||
public function getKeywordsData(): string {
|
||||
$keywords = $this->getSystemConfigurationValue("keywords");
|
||||
return implode(PHP_EOL, is_array($keywords) ? $keywords : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook headers configuration as formatted string
|
||||
*
|
||||
* Returns the configured HTTP headers as a newline-separated string
|
||||
* for display in the configuration form.
|
||||
*
|
||||
* @return string HTTP headers separated by newlines
|
||||
*/
|
||||
public function getWebhookHeaders(): string {
|
||||
$headers = $this->getSystemConfigurationValue("webhook_headers");
|
||||
return implode(
|
||||
PHP_EOL,
|
||||
is_array($headers) ? $headers : ($this->webhook_headers ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured webhook URL
|
||||
*
|
||||
* Returns the configured webhook URL or the default if none is set.
|
||||
*
|
||||
* @return string The webhook URL
|
||||
*/
|
||||
public function getWebhookUrl(): string {
|
||||
return (string)($this->getSystemConfigurationValue("webhook_url") ?? $this->webhook_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured webhook body template
|
||||
*
|
||||
* Returns the configured webhook body template or the default if none is set.
|
||||
*
|
||||
* @return string The webhook body template
|
||||
*/
|
||||
public function getWebhookBody(): string {
|
||||
$body = $this->getSystemConfigurationValue("webhook_body");
|
||||
return (!$body || $body === "") ? $this->webhook_body : (string)$body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured webhook body type
|
||||
*
|
||||
* Returns the configured body type (json/form) or the default if none is set.
|
||||
*
|
||||
* @return string The webhook body type
|
||||
*/
|
||||
public function getWebhookBodyType(): string {
|
||||
return (string)($this->getSystemConfigurationValue("webhook_body_type") ?? $this->webhook_body_type->value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility alias for logWarning function
|
||||
*
|
||||
* @deprecated Use logWarning() instead
|
||||
* @param bool $logEnabled Whether logging is enabled
|
||||
* @param mixed $data Data to log
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function _LOG(bool $logEnabled, $data): void {
|
||||
logWarning($logEnabled, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility alias for logError function
|
||||
*
|
||||
* @deprecated Use logError() instead
|
||||
* @param bool $logEnabled Whether logging is enabled
|
||||
* @param mixed $data Data to log
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function _LOG_ERR(bool $logEnabled, $data): void {
|
||||
logError($logEnabled, $data);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue