diff --git a/README.md b/README.md index 65f623c..ee3470c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Those extensions are in the current official extension repository, with automati * [Show Feed ID](xExtension-showFeedID/README.md): Show the ID of feed and category * [Title-Wrap](xExtension-TitleWrap/README.md): Applies a line-wrap to long article titles instead of truncating them * [Unsafe Autologin](xExtension-UnsafeAutologin/README.md): Brings back removed unsafe autologin feature from FreshRSS +* [Webhook](xExtension-Webhook/README.md): Automatically sends webhook notifications when RSS entries match specified keywords * [Word Highlighter](xExtension-WordHighlighter/README.md): Highlight specific words * [YouTube Video Feed](xExtension-YouTube/README.md): Embed YouTube feeds inside article content diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md new file mode 100644 index 0000000..53bc7ed --- /dev/null +++ b/xExtension-Webhook/README.md @@ -0,0 +1,228 @@ +# FreshRSS Webhook Extension + +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![FreshRSS](https://img.shields.io/badge/FreshRSS-1.20.0+-green.svg)](https://freshrss.org/) + +A powerful FreshRSS extension that automatically sends webhook notifications when RSS entries match configured search filters. Perfect for integrating with Discord, Slack, Telegram, or any service that supports webhooks. + +## 🚀 Features + +- **Automated Notifications**: Automatically sends webhooks when new RSS entries match your search filters +- **Search Filters**: FreshRSS native search filter syntax for precise matching +- **Multiple HTTP Methods**: Supports GET, POST, PUT +- **Configurable Formats**: Send data as JSON or form-encoded +- **Template System**: Customizable webhook payloads with placeholders +- **Error Handling**: Robust error handling with graceful fallbacks +- **Test Functionality**: Built-in test feature to verify webhook configuration + +## 📋 Requirements + +- FreshRSS 1.28.2+ + +## 🔧 Installation + +1. Download the extension files +2. Upload the `xExtension-Webhook` folder to your FreshRSS `extensions` directory +3. Enable the extension in FreshRSS admin panel under Extensions + +## ⚙️ Configuration + +### Basic Setup + +1. Go to **Administration** → **Extensions** → **Webhook** +2. Configure the following settings: + +#### Search Filter + +Use [FreshRSS search filter syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) to match entries. Each line is an OR condition — if any line matches, the webhook fires. Leave empty to match all entries. + +```text +intitle:breaking news +intitle:security alert +#your-project-name +``` + +#### Webhook Settings + +- **Webhook URL**: Your webhook endpoint URL +- **HTTP Method**: Choose from GET, POST, PUT, DELETE, etc. +- **Body Type**: JSON or Form-encoded +- **Headers**: Custom HTTP headers (one per line) + +### Webhook Body Template + +Customize the webhook payload using placeholders: + +```json +{ + "title": "{title}", + "feed": "{feed_name}", + "url": "{url}", + "content": "{content}", + "date": "{date}", + "timestamp": "{date_timestamp}", + "author": "{author}", + "tags": "{tags}" +} +``` + +#### Available Placeholders + +| Placeholder | Description | +| ----------- | ----------- | +| `{title}` | Article title | +| `{url}` | Article URL | +| `{content}` | Article content (HTML) | +| `{date}` | Publication date (string) | +| `{date_timestamp}` | Publication date as Unix timestamp | +| `{author}` | Article authors | +| `{feed_name}` | Feed name | +| `{feed_url}` | Feed URL | +| `{thumbnail_url}` | Thumbnail (image) URL | +| `{tags}` | Article tags (separated by " #") | + +## 🎯 Use Cases + +### Discord Webhook + +```json +{ + "content": "New article: **{title}**", + "embeds": [{ + "title": "{title}", + "url": "{url}", + "description": "{content}", + "color": 3447003, + "footer": { + "text": "{feed_name}" + } + }] +} +``` + +### Slack Webhook + +```json +{ + "text": "New article from {feed_name}", + "attachments": [{ + "title": "{title}", + "title_link": "{url}", + "text": "{content}", + "color": "good" + }] +} +``` + +### Custom API Integration + +```json +{ + "event": "new_article", + "data": { + "title": "{title}", + "url": "{url}", + "feed": "{feed_name}", + "timestamp": "{date_timestamp}" + } +} +``` + +## 🔍 Search Filters + +The extension uses [FreshRSS search filter syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) to match entries. +Examples: + +```text +intitle:security +inurl:example.com +author:John +#breaking-news +intitle:urgent OR intitle:critical +intitle:release -intitle:beta +``` + +## 🛠️ Advanced Configuration + +### Custom Headers + +Add authentication or custom headers: + +```text +Authorization: Bearer your-token-here +X-Custom-Header: custom-value +User-Agent: FreshRSS-Webhook/1.0 +``` + +### Error Handling + +- Failed webhooks are logged for debugging +- Network timeouts are handled gracefully +- Invalid configurations are validated + +### Performance + +- Only sends webhooks when filters match +- Efficient filter evaluation via FreshRSS core +- Minimal impact on RSS processing + +## 🐛 Troubleshooting + +### Common Issues + +**Webhooks not sending:** +- Check that a search filter is configured (or leave empty to match all) +- Verify webhook URL is accessible +- Check the FreshRSS logs for `[Webhook]` entries + +**Filter not matching:** +- Try simple filters first (e.g., `intitle:test`) +- Refer to the [FreshRSS filter documentation](https://freshrss.github.io/FreshRSS/en/users/10_filter.html) for syntax + +**Authentication errors:** +- Check custom headers configuration +- Verify webhook endpoint accepts your format + +### Debugging + +The extension logs to the FreshRSS log file with the `[Webhook]` prefix: +- **Debug level**: filter matches, HTTP requests sent (visible in `DEVELOPMENT` environment) +- **Warning level**: errors during article processing or failed requests (always visible) + +## 📝 Changelog + +### Version 0.1.1 + +- Initial release +- Automated webhook notifications +- Pattern matching in multiple fields +- Configurable HTTP methods and formats +- Comprehensive error handling +- Template-based webhook payloads + +## 🤝 Contributing + +This extension was developed to address [FreshRSS Issue #1513](https://github.com/FreshRSS/FreshRSS/issues/1513). + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Follow FreshRSS coding standards +4. Add tests for new functionality +5. Submit a pull request + +## 📄 License + +This extension is licensed under the [GNU Affero General Public License v3.0](LICENSE). + +## 🙏 Acknowledgments + +- FreshRSS development team for the excellent extension system +- Community members who requested and tested this feature +- Contributors to the original feature request + +## 📞 Support + +- [FreshRSS Documentation](https://freshrss.github.io/FreshRSS/) +- [GitHub Issues](https://github.com/FreshRSS/Extensions/issues) +- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 0000000..e58f1a4 --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,151 @@ + + +
+ + +
+ ⚙️ +
+ + +
+ +
+ +
+ +
+
+
+
+ +
+ 🌐 +
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{title}
{url}
{content}
{date_published}
{date_received}
{date_modified}
{date_user_modified}
{author}
{feed_name}
{feed_url}
{tags}
+
+
+
+
+ + +
+ getUserConfigurationString('webhook_content_type') === 'json' ? 'checked="checked"' : '' ?> /> + + + getUserConfigurationString('webhook_content_type') === 'form' ? 'checked="checked"' : '' ?> /> + +
+
+ +
+ +
+ +
+
+
+ +
+
+ + + +
+
+
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 0000000..96d175a --- /dev/null +++ b/xExtension-Webhook/extension.php @@ -0,0 +1,340 @@ + + */ + public array $webhook_headers = []; + + /** + * Default webhook request body template + * + * Supports placeholders like {title}, {url}, {feed_name}, etc. + * + * @var array + */ + public array $webhook_body = [ + 'title' => '{title}', + 'feed' => '{feed_name}', + 'url' => '{url}', + 'created' => '{date_published}', + ]; + + /** + * Initialize the extension + * + * Registers translation files and hooks into FreshRSS entry processing. + * + * @return void + */ + #[\Override] + public function init(): void { + $this->registerTranslates(); + $this->registerHook('entry_before_insert', [$this, 'processArticle']); + } + + /** + * Handle configuration form submission + * + * Processes configuration form data, saves settings, and optionally + * sends a test webhook request. + * + * @return void + * @throws Minz_ConfigurationException + * @throws Minz_PermissionDeniedException + */ + #[\Override] + public function handleConfigureAction(): void { + $this->registerTranslates(); + + if (Minz_Request::isPost()) { + $this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true))); + $this->setUserConfigurationValue('ignore_updated', Minz_Request::paramBoolean('ignore_updated')); + $this->setUserConfigurationValue('webhook_url', trim(Minz_Request::paramString('webhook_url', plaintext: true))); + $this->setUserConfigurationValue('webhook_method', trim(Minz_Request::paramString('webhook_method', plaintext: true))); + $this->setUserConfigurationValue('webhook_headers', + array_filter(Minz_Request::paramTextToArray('webhook_headers'), static fn(string $v): bool => $v !== '')); + $webhookBodyJson = trim(Minz_Request::paramString('webhook_body', plaintext: true)); + $webhookBodyArray = $webhookBodyJson === '' ? [] : json_decode($webhookBodyJson, true, 256, JSON_INVALID_UTF8_SUBSTITUTE); + $this->setUserConfigurationValue('webhook_body', is_array($webhookBodyArray) ? $webhookBodyArray : []); + $this->setUserConfigurationValue('webhook_body_type', trim(Minz_Request::paramString('webhook_body_type', plaintext: true))); + $this->setUserConfigurationValue('webhook_content_type', trim(Minz_Request::paramString('webhook_content_type', plaintext: true))); + + if (Minz_Request::paramString('test_request', plaintext: true) !== '') { + try { + $this->sendRequest( + $this->getUserConfigurationString('webhook_url') ?? '', + $this->getUserConfigurationString('webhook_method') ?? '', + $this->getUserConfigurationString('webhook_content_type') ?? 'json', + $this->getUserConfigurationArray('webhook_body') ?? [], + array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), + ); + } catch (Throwable $err) { + Minz_Log::warning('[Webhook] Test request failed: ' . $err->getMessage()); + } + } + } + } + + /** + * Process article and send webhook if search filter matches + * + * Evaluates RSS entries against configured search filters and sends + * webhook notifications for matching entries. + * + * @param FreshRSS_Entry $entry The RSS entry to process + * @throws Minz_PermissionDeniedException + * @return FreshRSS_Entry The processed entry (potentially marked as read) + */ + public function processArticle(FreshRSS_Entry $entry): FreshRSS_Entry { + if ($this->getUserConfigurationBool('ignore_updated') && $entry->isUpdated()) { + return $entry; + } + + try { + if (!$this->entryMatchesSearchFilter($entry)) { + return $entry; + } + $this->sendArticle($entry); + } catch (Throwable $err) { + Minz_Log::warning('[Webhook] Error processing article: ' . $err->getMessage()); + } + + return $entry; + } + + /** + * Check if entry matches the configured search filter + * + * Evaluates the entry against each line of the search filter. + * Lines act as OR conditions — the first match returns true. + * An empty filter matches all entries. + * + * @param FreshRSS_Entry $entry The RSS entry to check + * @return bool True if the entry matches any filter line, or if no filter is configured + */ + private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool { + $searchFilter = $this->getUserConfigurationString('search_filter') ?? ''; + if ($searchFilter === '') { + return true; + } + + $lines = array_filter(array_map('trim', explode("\n", $searchFilter)), static fn(string $line): bool => $line !== ''); + foreach ($lines as $line) { + $booleanSearch = new FreshRSS_BooleanSearch($line); + if ($entry->matches($booleanSearch)) { + return true; + } + } + + return false; + } + + /** + * Recursively replace placeholders in an array structure + * + * Walks the array and applies placeholder replacement to string leaf values. + * If a replacement value is null and the placeholder is the entire string value, + * the value becomes null (preserving null semantics in JSON output). + * + * @param array $data The array to process + * @param array $replacements Placeholder => replacement value map + * @return array The array with placeholders replaced + */ + private function replacePlaceholdersRecursive(array $data, array $replacements): array { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->replacePlaceholdersRecursive($value, $replacements); + } elseif (is_string($value)) { + // If the entire value is a single placeholder that maps to null, keep null + if (isset($replacements[$value]) || (array_key_exists($value, $replacements) && $replacements[$value] === null)) { + $data[$key] = $replacements[$value]; + } else { + $data[$key] = strtr($value, array_filter($replacements, static fn($v): bool => $v !== null)); + } + } + } + return $data; + } + + /** + * Send article data via webhook + * + * Prepares and sends webhook notification with article data. + * Supports custom body templates, GReader API JSON, and RSS XML formats. + * + * @param FreshRSS_Entry $entry The RSS entry to send + * @throws \RuntimeException + * @throws Minz_ConfigurationException + */ + private function sendArticle(FreshRSS_Entry $entry): void { + $bodyType = $this->getUserConfigurationString('webhook_body_type') ?? ''; + + switch ($bodyType) { + case 'greader': + $body = $entry->toGReader(); + $contentType = 'json'; + break; + case 'rss': + $body = $this->renderEntryAsRss($entry); + $contentType = 'rss'; + break; + default: + $contentType = $this->getUserConfigurationString('webhook_content_type') ?? 'json'; + $body = $this->getUserConfigurationArray('webhook_body') ?? $this->webhook_body; + + // Replace placeholders with actual values + $replacements = [ + '{title}' => htmlspecialchars_decode($entry->title(), ENT_QUOTES), + '{feed_name}' => htmlspecialchars_decode($entry->feed()?->name() ?? '', ENT_QUOTES), + '{feed_url}' => htmlspecialchars_decode($entry->feed()?->url() ?? '', ENT_QUOTES), + '{url}' => htmlspecialchars_decode($entry->link(), ENT_QUOTES), + '{content}' => htmlspecialchars_decode($entry->content(), ENT_QUOTES), + '{date_published}' => timestampToMachineDate($entry->date(raw: true)), + '{date_received}' => timestampToMachineDate($entry->dateAdded(raw: true)), + '{date_modified}' => $entry->lastModified() === null ? null : timestampToMachineDate($entry->lastModified()), + '{date_user_modified}' => $entry->lastUserModified() === null ? null : timestampToMachineDate($entry->lastUserModified()), + '{author}' => htmlspecialchars_decode($entry->authors(true), ENT_QUOTES), + '{tags}' => htmlspecialchars_decode($entry->tags(true), ENT_QUOTES), + ]; + + $body = $this->replacePlaceholdersRecursive($body, $replacements); + break; + } + + $this->sendRequest( + $this->getUserConfigurationString('webhook_url') ?? '', + $this->getUserConfigurationString('webhook_method') ?? '', + $contentType, + $body, + array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? [], 'is_string')), + ); + } + + /** + * Render an entry as RSS XML using the FreshRSS RSS view template + * + * @param FreshRSS_Entry $entry The RSS entry to render + * @return string The rendered RSS XML string + * @throws Minz_ConfigurationException + */ + private function renderEntryAsRss(FreshRSS_Entry $entry): string { + $view = new FreshRSS_View(); + $view->entries = [$entry]; + $view->internal_rendering = true; + $view->publishLabelsInsteadOfTags = false; + $view->entryIdsTagNames = []; + $view->rss_base = ''; + $view->image_url = ''; + + $feed = $entry->feed(); + $view->rss_title = $feed !== null ? htmlspecialchars_decode($feed->name(), ENT_QUOTES) : ''; + $view->html_url = $feed !== null ? htmlspecialchars_decode($feed->website()) : ''; + $view->rss_url = $feed !== null ? htmlspecialchars_decode($feed->url()) : ''; + $view->description = ''; + + $view->_layout(null); + $view->_path('index/rss.phtml'); + return $view->renderToString(); + } + + /** + * Send an HTTP request via the FreshRSS HTTP utility + * + * @param string $url Target URL + * @param string $method HTTP method (GET, POST, PUT, etc.) + * @param string $contentType Content type ('json', 'form', or 'rss') + * @param array|string $body Request body as an array or string + * @param list $headers HTTP headers + * @throws \RuntimeException + */ + private function sendRequest(string $url, string $method, string $contentType, array|string $body, array $headers = []): void { + if ($url === '') { + throw new RuntimeException('Webhook URL is empty'); + } + + $processedBody = null; + if ($body !== '' && $body !== [] && $method !== 'GET') { + if (is_string($body)) { + $processedBody = $body; + } else { + $processedBody = match ($contentType) { + 'form' => http_build_query($body), + default => json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + }; + } + } + + if (empty($headers)) { + $headers = match ($contentType) { + 'form' => ['Content-Type: application/x-www-form-urlencoded'], + 'rss' => ['Content-Type: application/rss+xml; charset=utf-8'], + default => ['Content-Type: application/json'], + }; + } + + $curlOptions = [ + CURLOPT_HTTPHEADER => array_values($headers), + CURLOPT_TIMEOUT => 10, + ]; + + if ($method === 'POST') { + $curlOptions[CURLOPT_POST] = true; + } elseif ($method !== 'GET') { + $curlOptions[CURLOPT_CUSTOMREQUEST] = $method; + } + + if ($processedBody !== null && $method !== 'GET') { + $curlOptions[CURLOPT_POSTFIELDS] = $processedBody; + } + + $response = FreshRSS_http_Util::httpGet($url, cachePath: null, type: 'json', curl_options: $curlOptions); + + if ($response['fail'] ?? false) { + throw new RuntimeException('HTTP request failed for URL: ' . $url); + } + } + + /** + * 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 = array_values(array_filter($this->getUserConfigurationArray('webhook_headers') ?? $this->webhook_headers, 'is_string')); + return implode(PHP_EOL, $headers); + } +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 0000000..29eabee --- /dev/null +++ b/xExtension-Webhook/i18n/en/ext.php @@ -0,0 +1,36 @@ + array( + 'event_settings' => 'Event settings', + 'show_hide' => 'Show/hide', + 'webhook_settings' => 'Webhook settings', + 'save_and_send_test_req' => 'Save and send test request', + 'description' => 'Webhooks allow external services to be notified when certain events happen.
When the specified events happen, we’ll send a HTTP request (usually POST) to the URL you provide.', + 'search_filter' => 'Search filter', + 'search_filter_description' => 'Uses FreshRSS search filter syntax. Each line is an OR condition. Leave empty to match all entries.', + 'http_body' => 'HTTP Body', + 'http_body_description' => 'Must be valid JSON or form data (x-www-form-urlencoded)', + 'http_body_placeholder_summary' => 'You can use special placeholders that will be replaced by the actual values:', + 'http_body_placeholder_title' => 'Placeholder', + 'http_body_placeholder_description' => 'Description', + 'http_body_placeholder_title_description' => 'Article title', + 'http_body_placeholder_url_description' => 'HTML-encoded link of the article', + 'http_body_placeholder_content_description' => 'Content of the article (HTML format)', + 'http_body_placeholder_authors_description' => 'Authors of the article', + 'http_body_placeholder_feed_description' => 'Name of the feed', + 'http_body_placeholder_feed_url_description' => 'URL of the feed', + 'http_body_placeholder_tags_description' => 'Article tags (string, separated by " #")', + 'http_body_placeholder_date_published_description' => 'Publication date (ISO 8601)', + 'http_body_placeholder_date_received_description' => 'Date received by FreshRSS (ISO 8601)', + 'http_body_placeholder_date_modified_description' => 'Last modified date (ISO 8601, null if never modified)', + 'http_body_placeholder_date_user_modified_description' => 'Last user-modified date (ISO 8601, null if never modified)', + 'webhook_headers' => 'HTTP Headers
(one per line)', + 'http_body_type' => 'HTTP Body type', + 'http_content_type' => 'Content encoding', + 'custom_body_type' => 'Custom', + 'greader_body_type' => 'GReader API (JSON)', + 'greader_body_type_description' => 'Sends the full article as a GReader API-compatible JSON object.', + 'rss_body_type' => 'RSS (XML)', + ), +); diff --git a/xExtension-Webhook/i18n/fr/ext.php b/xExtension-Webhook/i18n/fr/ext.php new file mode 100644 index 0000000..1c35892 --- /dev/null +++ b/xExtension-Webhook/i18n/fr/ext.php @@ -0,0 +1,36 @@ + array( + 'event_settings' => 'Paramètres des événements', + 'show_hide' => 'Afficher/masquer', + 'webhook_settings' => 'Paramètres du webhook', + 'save_and_send_test_req' => 'Enregistrer et envoyer une requête de test', + 'description' => 'Les webhooks permettent de notifier des services externes lorsque certains événements se produisent.
Lorsque les événements spécifiés se produisent, nous envoyons une requête HTTP (généralement POST) à l’URL que vous fournissez.', + 'search_filter' => 'Filtre de recherche', + 'search_filter_description' => 'Utilise la syntaxe de filtre de recherche FreshRSS. Chaque ligne est une condition OU. Laisser vide pour correspondre à tous les articles.', + 'http_body' => 'Corps HTTP', + 'http_body_description' => 'Doit être du JSON valide ou des données de formulaire (x-www-form-urlencoded)', + 'http_body_placeholder_summary' => 'Vous pouvez utiliser des espaces réservés spéciaux qui seront remplacés par les valeurs réelles :', + 'http_body_placeholder_title' => 'Espace réservé', + 'http_body_placeholder_description' => 'Description', + 'http_body_placeholder_title_description' => 'Titre de l’article', + 'http_body_placeholder_url_description' => 'Lien de l’article encodé en HTML', + 'http_body_placeholder_content_description' => 'Contenu de l’article (format HTML)', + 'http_body_placeholder_authors_description' => 'Auteurs de l’article', + 'http_body_placeholder_feed_description' => 'Nom du flux', + 'http_body_placeholder_feed_url_description' => 'URL du flux', + 'http_body_placeholder_tags_description' => 'Étiquettes de l’article (chaîne, séparées par « # »)', + 'http_body_placeholder_date_published_description' => 'Date de publication (ISO 8601)', + 'http_body_placeholder_date_received_description' => 'Date de réception par FreshRSS (ISO 8601)', + 'http_body_placeholder_date_modified_description' => 'Date de dernière modification (ISO 8601, null si jamais modifié)', + 'http_body_placeholder_date_user_modified_description' => 'Date de dernière modification par l’utilisateur (ISO 8601, null si jamais modifié)', + 'webhook_headers' => 'En-têtes HTTP
(un par ligne)', + 'http_body_type' => 'Type de corps HTTP', + 'http_content_type' => 'Encodage du contenu', + 'custom_body_type' => 'Personnalisé', + 'greader_body_type' => 'GReader API (JSON)', + 'greader_body_type_description' => 'Envoie l’article complet sous forme d’objet JSON compatible avec l’API GReader.', + 'rss_body_type' => 'RSS (XML)', + ), +); diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json new file mode 100644 index 0000000..71498f4 --- /dev/null +++ b/xExtension-Webhook/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "Webhook", + "author": "Lukas Melega, Ryahn", + "description": "Send custom webhook when new article appears (and matches custom criteria)", + "version": "0.2.0", + "entrypoint": "Webhook", + "type": "user", + "compatibility": "1.28.2" +}