Merge 97d5a36fd7 into 361a428139
This commit is contained in:
commit
b43d515b65
7 changed files with 1275 additions and 0 deletions
19
xExtension-Webhook/LICENSE
Normal file
19
xExtension-Webhook/LICENSE
Normal file
|
|
@ -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.
|
||||||
242
xExtension-Webhook/README.md
Normal file
242
xExtension-Webhook/README.md
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
# FreshRSS Webhook Extension
|
||||||
|
|
||||||
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
[](https://freshrss.org/)
|
||||||
|
|
||||||
|
A powerful FreshRSS extension that automatically sends webhook notifications when RSS entries match specified keywords. Perfect for integrating with Discord, Slack, Telegram, or any service that supports webhooks.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Automated Notifications**: Automatically sends webhooks when new RSS entries match your keywords
|
||||||
|
- **Flexible Pattern Matching**: Search in titles, feed names, authors, or content
|
||||||
|
- **Multiple HTTP Methods**: Supports GET, POST, PUT, DELETE, PATCH, OPTIONS, and HEAD
|
||||||
|
- **Configurable Formats**: Send data as JSON or form-encoded
|
||||||
|
- **Template System**: Customizable webhook payloads with placeholders
|
||||||
|
- **Comprehensive Logging**: Detailed logging for debugging and monitoring
|
||||||
|
- **Error Handling**: Robust error handling with graceful fallbacks
|
||||||
|
- **Test Functionality**: Built-in test feature to verify webhook configuration
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- FreshRSS 1.20.0 or later
|
||||||
|
- PHP 8.1 or later
|
||||||
|
- cURL extension enabled
|
||||||
|
|
||||||
|
## 🔧 Installation
|
||||||
|
|
||||||
|
1. Download the extension files
|
||||||
|
2. Upload the `xExtension-Webhook` folder to your FreshRSS `extensions` directory
|
||||||
|
3. Enable the extension in FreshRSS admin panel under Extensions
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
1. Go to **Administration** → **Extensions** → **Webhook**
|
||||||
|
2. Configure the following settings:
|
||||||
|
|
||||||
|
#### Keywords
|
||||||
|
|
||||||
|
Enter keywords to match against RSS entries (one per line):
|
||||||
|
|
||||||
|
```text
|
||||||
|
breaking news
|
||||||
|
security alert
|
||||||
|
your-project-name
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search Options
|
||||||
|
|
||||||
|
- **Search in Title**: Match keywords in article titles
|
||||||
|
- **Search in Feed**: Match keywords in feed names
|
||||||
|
- **Search in Authors**: Match keywords in author names
|
||||||
|
- **Search in Content**: Match keywords in article content
|
||||||
|
|
||||||
|
#### Webhook Settings
|
||||||
|
|
||||||
|
- **Webhook URL**: Your webhook endpoint URL
|
||||||
|
- **HTTP Method**: Choose from GET, POST, PUT, DELETE, etc.
|
||||||
|
- **Body Type**: JSON or Form-encoded
|
||||||
|
- **Headers**: Custom HTTP headers (one per line)
|
||||||
|
|
||||||
|
### Webhook Body Template
|
||||||
|
|
||||||
|
Customize the webhook payload using placeholders:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "__TITLE__",
|
||||||
|
"feed": "__FEED__",
|
||||||
|
"url": "__URL__",
|
||||||
|
"content": "__CONTENT__",
|
||||||
|
"date": "__DATE__",
|
||||||
|
"timestamp": "__DATE_TIMESTAMP__",
|
||||||
|
"authors": "__AUTHORS__",
|
||||||
|
"tags": "__TAGS__"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Placeholders
|
||||||
|
|
||||||
|
| Placeholder | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `__TITLE__` | Article title |
|
||||||
|
| `__FEED__` | Feed name |
|
||||||
|
| `__URL__` | Article URL |
|
||||||
|
| `__CONTENT__` | Article content |
|
||||||
|
| `__DATE__` | Publication date |
|
||||||
|
| `__DATE_TIMESTAMP__` | Unix timestamp |
|
||||||
|
| `__AUTHORS__` | Article authors |
|
||||||
|
| `__TAGS__` | Article tags |
|
||||||
|
|
||||||
|
## 🎯 Use Cases
|
||||||
|
|
||||||
|
### Discord Webhook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "New article: **__TITLE__**",
|
||||||
|
"embeds": [{
|
||||||
|
"title": "__TITLE__",
|
||||||
|
"url": "__URL__",
|
||||||
|
"description": "__CONTENT__",
|
||||||
|
"color": 3447003,
|
||||||
|
"footer": {
|
||||||
|
"text": "__FEED__"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slack Webhook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "New article from __FEED__",
|
||||||
|
"attachments": [{
|
||||||
|
"title": "__TITLE__",
|
||||||
|
"title_link": "__URL__",
|
||||||
|
"text": "__CONTENT__",
|
||||||
|
"color": "good"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom API Integration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "new_article",
|
||||||
|
"data": {
|
||||||
|
"title": "__TITLE__",
|
||||||
|
"url": "__URL__",
|
||||||
|
"feed": "__FEED__",
|
||||||
|
"timestamp": "__DATE_TIMESTAMP__"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Pattern Matching
|
||||||
|
|
||||||
|
The extension supports both regex patterns and simple string matching:
|
||||||
|
|
||||||
|
### Regex Patterns
|
||||||
|
|
||||||
|
```text
|
||||||
|
/security.*/i
|
||||||
|
/\b(urgent|critical)\b/i
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simple Strings
|
||||||
|
|
||||||
|
```text
|
||||||
|
breaking news
|
||||||
|
security alert
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Advanced Configuration
|
||||||
|
|
||||||
|
### Custom Headers
|
||||||
|
|
||||||
|
Add authentication or custom headers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer your-token-here
|
||||||
|
X-Custom-Header: custom-value
|
||||||
|
User-Agent: FreshRSS-Webhook/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Failed webhooks are logged for debugging
|
||||||
|
- Network timeouts are handled gracefully
|
||||||
|
- Invalid configurations are validated
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Only sends webhooks when patterns match
|
||||||
|
- Efficient pattern matching with fallbacks
|
||||||
|
- Minimal impact on RSS processing
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**Webhooks not sending:**
|
||||||
|
- Check that keywords are configured
|
||||||
|
- Verify webhook URL is accessible
|
||||||
|
- Enable logging to see detailed information
|
||||||
|
|
||||||
|
**Pattern not matching:**
|
||||||
|
- Test with simple string patterns first
|
||||||
|
- Check regex syntax if using regex patterns
|
||||||
|
- Verify search options are enabled
|
||||||
|
|
||||||
|
**Authentication errors:**
|
||||||
|
- Check custom headers configuration
|
||||||
|
- Verify webhook endpoint accepts your format
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
Enable logging in the extension settings to see detailed information about:
|
||||||
|
- Pattern matching results
|
||||||
|
- HTTP request details
|
||||||
|
- Response codes and errors
|
||||||
|
|
||||||
|
## 📝 Changelog
|
||||||
|
|
||||||
|
### Version 0.1.1
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
- Automated webhook notifications
|
||||||
|
- Pattern matching in multiple fields
|
||||||
|
- Configurable HTTP methods and formats
|
||||||
|
- Comprehensive error handling and logging
|
||||||
|
- Template-based webhook payloads
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
This extension was developed to address [FreshRSS Issue #1513](https://github.com/FreshRSS/FreshRSS/issues/1513).
|
||||||
|
|
||||||
|
Contributions are welcome! Please:
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Follow FreshRSS coding standards
|
||||||
|
4. Add tests for new functionality
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This extension is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- FreshRSS development team for the excellent extension system
|
||||||
|
- Community members who requested and tested this feature
|
||||||
|
- Contributors to the original feature request
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
- [FreshRSS Documentation](https://freshrss.github.io/FreshRSS/)
|
||||||
|
- [GitHub Issues](https://github.com/FreshRSS/Extensions/issues)
|
||||||
|
- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions)
|
||||||
199
xExtension-Webhook/configure.phtml
Normal file
199
xExtension-Webhook/configure.phtml
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/** @var WebhookExtension $this */
|
||||||
|
?>
|
||||||
|
<small>
|
||||||
|
<?= _t('ext.webhook.description') ?>
|
||||||
|
</small>
|
||||||
|
<form action="<?= _url('extension', 'configure', 'e', urlencode($this->getName())) ?>" method="post">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend><?= _t('ext.webhook.event_settings') ?> ⚙️</legend>
|
||||||
|
<details open="open">
|
||||||
|
<summary class="stick"><small><?= _t('ext.webhook.show_hide') ?></small></summary>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="keywords"><?= _t('ext.webhook.keywords') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<textarea name="keywords" id="keywords"><?= $this->getKeywordsData() ?></textarea>
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
<?= _t('ext.webhook.keywords_description') ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search in article's: -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="search_in"><?= _t('ext.webhook.search_in') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td class="group">
|
||||||
|
<input type="checkbox" name="search_in_title" id="search_in_title" value="1"
|
||||||
|
<?= FreshRSS_Context::userConf()->attributeBool('search_in_title') ? 'checked="checked"' : '' ?> />
|
||||||
|
<label for="search_in_title"><b><?= _t('ext.webhook.search_in_title_label') ?></b> </label>
|
||||||
|
</td>
|
||||||
|
<td class="group">
|
||||||
|
<input type="checkbox" name="search_in_feed" id="search_in_feed" value="1"
|
||||||
|
<?= FreshRSS_Context::userConf()->attributeBool('search_in_feed') ? 'checked="checked"' : '' ?> />
|
||||||
|
<label for="search_in_feed"><?= _t('ext.webhook.search_in_feed_label') ?></label>
|
||||||
|
</td>
|
||||||
|
<td class="group">
|
||||||
|
<input type="checkbox" name="search_in_authors" id="search_in_authors" value="1"
|
||||||
|
<?= FreshRSS_Context::userConf()->attributeBool('search_in_authors') ? 'checked="checked"' : '' ?> />
|
||||||
|
<label for="search_in_authors"><?= _t('ext.webhook.search_in_authors_label') ?></label>
|
||||||
|
</td>
|
||||||
|
<td class="group">
|
||||||
|
<input type="checkbox" name="search_in_content" id="search_in_content" value="1"
|
||||||
|
<?= FreshRSS_Context::userConf()->attributeBool('search_in_content') ? 'checked="checked"' : '' ?> />
|
||||||
|
<label for="search_in_content"><?= _t('ext.webhook.search_in_content_label') ?></label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="mark_as_read"><?= _t('ext.webhook.mark_as_read') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<input type="checkbox" name="mark_as_read" id="mark_as_read" value="1"
|
||||||
|
<?= FreshRSS_Context::userConf()->attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- ---------------------[ HTTP settings ]-------------------------------------- -->
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend><?= _t('ext.webhook.webhook_settings') ?> 🌐</legend>
|
||||||
|
<details open="open">
|
||||||
|
<summary class="stick"> <small><?= _t('ext.webhook.show_hide') ?></small> </summary>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="webhook_url">Method / URL:</label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<select id="webhook_method" name="webhook_method">
|
||||||
|
<?php
|
||||||
|
$currentMethod = FreshRSS_Context::userConf()->attributeString('webhook_method') ?? $this->webhook_method->value;
|
||||||
|
$supportedMethods = [HTTP_METHOD::POST, HTTP_METHOD::GET, HTTP_METHOD::PUT];
|
||||||
|
foreach ($supportedMethods as $method): ?>
|
||||||
|
<option value="<?= $method->value ?>" <?= $currentMethod === $method->value ? 'selected' : '' ?>><?= $method->value ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="webhook_url" id="webhook_url"
|
||||||
|
value="<?= $this->getWebhookUrl() ?>" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="webhook_body"><?= _t('ext.webhook.http_body') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<textarea name="webhook_body" id="webhook_body"><?= $this->getWebhookBody() ?></textarea>
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
<?= _t('ext.webhook.http_body_description') ?>
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><small><b class=""><?= _t('ext.webhook.http_body_placeholder_summary') ?></b></small>
|
||||||
|
</summary>
|
||||||
|
<div>
|
||||||
|
<div class="content">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th><b><code><?= _t('ext.webhook.http_body_placeholder_title') ?></code></b></th>
|
||||||
|
<th><b><?= _t('ext.webhook.http_body_placeholder_description') ?></b></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__TITLE__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_title_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__URL__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_url_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__CONTENT__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_content_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__DATE__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_date_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__DATE_TIMESTAMP__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_date_timestamp_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__AUTHORS__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_authors_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__THUMBNAIL_URL__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_thumbnail_url_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__FEED__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_feed_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>__TAGS__</code></td>
|
||||||
|
<td><?= _t('ext.webhook.http_body_placeholder_tags_description') ?></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ---------------------[ More options ]-------------------------------------- -->
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong class=""><?= _t('ext.webhook.more_options') ?></strong></summary>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="webhook_headers"><?= _t('ext.webhook.webhook_headers') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<textarea name="webhook_headers"
|
||||||
|
id="webhook_headers"><?= $this->getWebhookHeaders() ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="group-name" for="webhook_body_type"><?= _t('ext.webhook.http_body_type') ?></label>
|
||||||
|
<div class="group-controls">
|
||||||
|
<input type="radio" id="webhook_body_type_json" name="webhook_body_type" value="json"
|
||||||
|
<?= $this->getWebhookBodyType() === 'json' ? 'checked' : '' ?>>
|
||||||
|
<label for="webhook_body_type_json" class="group"><code>JSON</code></label>
|
||||||
|
|
||||||
|
<input type="radio" id="webhook_body_type_form" name="webhook_body_type" value="form"
|
||||||
|
<?= $this->getWebhookBodyType() === 'form' ? 'checked' : '' ?>>
|
||||||
|
<label for="webhook_body_type_form" class="group"><code>x-www-form-urlencoded</code></label>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><span class=""><?= _t('ext.webhook.more_info') ?></span></summary>
|
||||||
|
<small><?= _t('ext.webhook.more_info_description') ?></small>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- ---------------------[ SUBMIT & CANCEL ]-------------------------------------- -->
|
||||||
|
<div class="form-group form-actions">
|
||||||
|
<div class="group-controls">
|
||||||
|
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
|
||||||
|
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
|
||||||
|
<button type="menu" class="btn"><?= _t('ext.webhook.save_and_send_test_req') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
453
xExtension-Webhook/extension.php
Normal file
453
xExtension-Webhook/extension.php
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
<?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
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*/
|
||||||
|
public function handleConfigureAction(): void {
|
||||||
|
$this->registerTranslates();
|
||||||
|
|
||||||
|
if (Minz_Request::isPost()) {
|
||||||
|
$conf = [
|
||||||
|
"keywords" => array_filter(Minz_Request::paramTextToArray("keywords")),
|
||||||
|
"search_in_title" => Minz_Request::paramString("search_in_title"),
|
||||||
|
"search_in_feed" => Minz_Request::paramString("search_in_feed"),
|
||||||
|
"search_in_authors" => Minz_Request::paramString("search_in_authors"),
|
||||||
|
"search_in_content" => Minz_Request::paramString("search_in_content"),
|
||||||
|
"mark_as_read" => Minz_Request::paramBoolean("mark_as_read"),
|
||||||
|
"ignore_updated" => Minz_Request::paramBoolean("ignore_updated"),
|
||||||
|
|
||||||
|
"webhook_url" => Minz_Request::paramString("webhook_url"),
|
||||||
|
"webhook_method" => Minz_Request::paramString("webhook_method"),
|
||||||
|
"webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers")),
|
||||||
|
"webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")),
|
||||||
|
"webhook_body_type" => Minz_Request::paramString("webhook_body_type"),
|
||||||
|
"enable_logging" => Minz_Request::paramBoolean("enable_logging"),
|
||||||
|
];
|
||||||
|
$this->setSystemConfiguration($conf);
|
||||||
|
$logsEnabled = $conf["enable_logging"];
|
||||||
|
$this->logsEnabled = $conf["enable_logging"];
|
||||||
|
|
||||||
|
logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf));
|
||||||
|
|
||||||
|
if (Minz_Request::paramString("test_request")) {
|
||||||
|
try {
|
||||||
|
sendReq(
|
||||||
|
$conf["webhook_url"],
|
||||||
|
$conf["webhook_method"],
|
||||||
|
$conf["webhook_body_type"],
|
||||||
|
$conf["webhook_body"],
|
||||||
|
$conf["webhook_headers"],
|
||||||
|
$conf["enable_logging"],
|
||||||
|
"Test request from configuration"
|
||||||
|
);
|
||||||
|
} catch (Throwable $err) {
|
||||||
|
logError($logsEnabled, "Test request failed: {$err->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process article and send webhook if patterns match
|
||||||
|
*
|
||||||
|
* Analyzes RSS entries against configured keyword patterns and sends
|
||||||
|
* webhook notifications for matching entries. Supports pattern matching
|
||||||
|
* in titles, feeds, authors, and content.
|
||||||
|
*
|
||||||
|
* @param FreshRSS_Entry $entry The RSS entry to process
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return FreshRSS_Entry The processed entry (potentially marked as read)
|
||||||
|
*/
|
||||||
|
public function processArticle($entry): FreshRSS_Entry {
|
||||||
|
if (!is_object($entry)) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) {
|
||||||
|
logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title());
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchInTitle = FreshRSS_Context::userConf()->attributeBool('search_in_title') ?? false;
|
||||||
|
$searchInFeed = FreshRSS_Context::userConf()->attributeBool('search_in_feed') ?? false;
|
||||||
|
$searchInAuthors = FreshRSS_Context::userConf()->attributeBool('search_in_authors') ?? false;
|
||||||
|
$searchInContent = FreshRSS_Context::userConf()->attributeBool('search_in_content') ?? false;
|
||||||
|
|
||||||
|
$patterns = FreshRSS_Context::userConf()->attributeArray('keywords') ?? [];
|
||||||
|
$markAsRead = FreshRSS_Context::userConf()->attributeBool('mark_as_read') ?? false;
|
||||||
|
$logsEnabled = FreshRSS_Context::userConf()->attributeBool('enable_logging') ?? false;
|
||||||
|
$this->logsEnabled = $logsEnabled;
|
||||||
|
|
||||||
|
// Validate patterns
|
||||||
|
if (!is_array($patterns) || empty($patterns)) {
|
||||||
|
logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings.");
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = "❗️NOT INITIALIZED";
|
||||||
|
$link = "❗️NOT INITIALIZED";
|
||||||
|
$additionalLog = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$title = $entry->title();
|
||||||
|
$link = $entry->link();
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
$matchFound = false;
|
||||||
|
|
||||||
|
if ($searchInTitle && $this->isPatternFound("/{$pattern}/", $title)) {
|
||||||
|
logWarning($logsEnabled, "matched item by title ✔️ \"{$title}\" ❖ link: {$link}");
|
||||||
|
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ title \"{$title}\" ❖ link: {$link}";
|
||||||
|
$matchFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matchFound && $searchInFeed && is_object($entry->feed()) && $this->isPatternFound("/{$pattern}/", $entry->feed()->name())) {
|
||||||
|
logWarning($logsEnabled, "matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}");
|
||||||
|
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}";
|
||||||
|
$matchFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matchFound && $searchInAuthors && $this->isPatternFound("/{$pattern}/", $entry->authors(true))) {
|
||||||
|
logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}");
|
||||||
|
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}";
|
||||||
|
$matchFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matchFound && $searchInContent && $this->isPatternFound("/{$pattern}/", $entry->content())) {
|
||||||
|
logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}");
|
||||||
|
$additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}";
|
||||||
|
$matchFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matchFound) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($markAsRead) {
|
||||||
|
$entry->_isRead($markAsRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only send webhook if a pattern was matched
|
||||||
|
if (!empty($additionalLog)) {
|
||||||
|
$this->sendArticle($entry, $additionalLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Throwable $err) {
|
||||||
|
logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send article data via webhook
|
||||||
|
*
|
||||||
|
* Prepares and sends webhook notification with article data.
|
||||||
|
* Replaces template placeholders with actual entry values.
|
||||||
|
*
|
||||||
|
* @param FreshRSS_Entry $entry The RSS entry to send
|
||||||
|
* @param string $additionalLog Additional context for logging
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void {
|
||||||
|
try {
|
||||||
|
$bodyStr = FreshRSS_Context::userConf()->attributeString('webhook_body');
|
||||||
|
|
||||||
|
// Replace placeholders with actual values
|
||||||
|
$replacements = [
|
||||||
|
"__TITLE__" => $this->toSafeJsonStr($entry->title()),
|
||||||
|
"__FEED__" => $this->toSafeJsonStr($entry->feed()->name()),
|
||||||
|
"__URL__" => $this->toSafeJsonStr($entry->link()),
|
||||||
|
"__CONTENT__" => $this->toSafeJsonStr($entry->content()),
|
||||||
|
"__DATE__" => $this->toSafeJsonStr($entry->date()),
|
||||||
|
"__DATE_TIMESTAMP__" => $this->toSafeJsonStr($entry->date(true)),
|
||||||
|
"__AUTHORS__" => $this->toSafeJsonStr($entry->authors(true)),
|
||||||
|
"__TAGS__" => $this->toSafeJsonStr($entry->tags(true)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr);
|
||||||
|
|
||||||
|
sendReq(
|
||||||
|
FreshRSS_Context::userConf()->attributeString('webhook_url'),
|
||||||
|
FreshRSS_Context::userConf()->attributeString('webhook_method'),
|
||||||
|
FreshRSS_Context::userConf()->attributeString('webhook_body_type'),
|
||||||
|
$bodyStr,
|
||||||
|
FreshRSS_Context::userConf()->attributeArray('webhook_headers'),
|
||||||
|
FreshRSS_Context::userConf()->attributeBool('enable_logging'),
|
||||||
|
$additionalLog,
|
||||||
|
);
|
||||||
|
} catch (Throwable $err) {
|
||||||
|
logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert string/int to safe JSON string
|
||||||
|
*
|
||||||
|
* Sanitizes input values for safe inclusion in JSON payloads
|
||||||
|
* by removing quotes and decoding HTML entities.
|
||||||
|
*
|
||||||
|
* @param string|int $str Input value to sanitize
|
||||||
|
*
|
||||||
|
* @return string Sanitized string safe for JSON inclusion
|
||||||
|
*/
|
||||||
|
private function toSafeJsonStr(string|int $str): string {
|
||||||
|
if (is_numeric($str)) {
|
||||||
|
return (string)$str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove quotes and decode HTML entities
|
||||||
|
return str_replace('"', '', html_entity_decode((string)$str));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pattern is found in text
|
||||||
|
*
|
||||||
|
* Attempts regex matching first, then falls back to simple string search.
|
||||||
|
* Handles regex errors gracefully and logs issues.
|
||||||
|
*
|
||||||
|
* @param string $pattern Search pattern (may include regex delimiters)
|
||||||
|
* @param string $text Text to search in
|
||||||
|
*
|
||||||
|
* @return bool True if pattern is found, false otherwise
|
||||||
|
*/
|
||||||
|
private function isPatternFound(string $pattern, string $text): bool {
|
||||||
|
if (empty($text) || empty($pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try regex match first
|
||||||
|
if (preg_match($pattern, $text) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string search (remove regex delimiters)
|
||||||
|
$cleanPattern = trim($pattern, '/');
|
||||||
|
return str_contains($text, $cleanPattern);
|
||||||
|
|
||||||
|
} catch (Throwable $err) {
|
||||||
|
logError($this->logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err->getMessage()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keywords configuration as formatted string
|
||||||
|
*
|
||||||
|
* Returns the configured keywords as a newline-separated string
|
||||||
|
* for display in the configuration form.
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
*
|
||||||
|
* @return string Keywords separated by newlines
|
||||||
|
*/
|
||||||
|
public function getKeywordsData(): string {
|
||||||
|
$keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? [];
|
||||||
|
return implode(PHP_EOL, $keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhook headers configuration as formatted string
|
||||||
|
*
|
||||||
|
* Returns the configured HTTP headers as a newline-separated string
|
||||||
|
* for display in the configuration form.
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
*
|
||||||
|
* @return string HTTP headers separated by newlines
|
||||||
|
*/
|
||||||
|
public function getWebhookHeaders(): string {
|
||||||
|
$headers = FreshRSS_Context::userConf()->attributeArray('webhook_headers');
|
||||||
|
return implode(
|
||||||
|
PHP_EOL,
|
||||||
|
is_array($headers) ? $headers : ($this->webhook_headers ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configured webhook URL
|
||||||
|
*
|
||||||
|
* Returns the configured webhook URL or the default if none is set.
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
*
|
||||||
|
* @return string The webhook URL
|
||||||
|
*/
|
||||||
|
public function getWebhookUrl(): string {
|
||||||
|
return FreshRSS_Context::userConf()->attributeString('webhook_url') ?? $this->webhook_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configured webhook body template
|
||||||
|
*
|
||||||
|
* Returns the configured webhook body template or the default if none is set.
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
*
|
||||||
|
* @return string The webhook body template
|
||||||
|
*/
|
||||||
|
public function getWebhookBody(): string {
|
||||||
|
$body = FreshRSS_Context::userConf()->attributeString('webhook_body');
|
||||||
|
return ($body === null || $body === '') ? $this->webhook_body : $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configured webhook body type
|
||||||
|
*
|
||||||
|
* Returns the configured body type (json/form) or the default if none is set.
|
||||||
|
*
|
||||||
|
* @throws FreshRSS_Context_Exception
|
||||||
|
*
|
||||||
|
* @return string The webhook body type
|
||||||
|
*/
|
||||||
|
public function getWebhookBodyType(): string {
|
||||||
|
return FreshRSS_Context::userConf()->attributeString('webhook_body_type') ?? $this->webhook_body_type->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility alias for logWarning function
|
||||||
|
*
|
||||||
|
* @deprecated Use logWarning() instead
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function _LOG(bool $logEnabled, $data): void {
|
||||||
|
logWarning($logEnabled, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility alias for logError function
|
||||||
|
*
|
||||||
|
* @deprecated Use logError() instead
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function _LOG_ERR(bool $logEnabled, $data): void {
|
||||||
|
logError($logEnabled, $data);
|
||||||
|
}
|
||||||
45
xExtension-Webhook/i18n/en/ext.php
Normal file
45
xExtension-Webhook/i18n/en/ext.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'webhook' => array(
|
||||||
|
'event_settings' => 'Event settings',
|
||||||
|
'show_hide' => 'show/hide',
|
||||||
|
'webhook_settings' => 'Webhook settings',
|
||||||
|
'more_options' => 'More options (headers, format,…):',
|
||||||
|
'save_and_send_test_req' => 'Save and send test request',
|
||||||
|
'description' => 'Webhooks allow external services to be notified when certain events happen.\nWhen the specified events happen, we\'ll send a HTTP request (usually POST) to the URL you provide.',
|
||||||
|
'keywords' => 'Keywords in the new article',
|
||||||
|
'search_in' => 'Search in article\'s:',
|
||||||
|
'search_in_title' => 'title',
|
||||||
|
'search_in_feed' => 'feed',
|
||||||
|
'search_in_content' => 'content',
|
||||||
|
'search_in_all' => 'all',
|
||||||
|
'search_in_none' => 'none',
|
||||||
|
'keywords_description' => 'Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined (they are evaluated using the PHP function <code>preg_match</code>).',
|
||||||
|
'search_in_title_label' => '🪧 title * </b> ',
|
||||||
|
'search_in_feed_label' => '💼 feed ',
|
||||||
|
'search_in_authors_label' => '👥 authors ',
|
||||||
|
'search_in_content_label' => '📄 content ',
|
||||||
|
'mark_as_read' => 'Mark as read',
|
||||||
|
'mark_as_read_description' => 'Mark the article as read after sending the webhook.',
|
||||||
|
'mark_as_read_label' => 'Mark as read',
|
||||||
|
'http_body' => 'HTTP Body',
|
||||||
|
'http_body_description' => 'Must be valid <b>JSON</b> or form data (<b>x-www-form-urlencoded</b>)',
|
||||||
|
'http_body_placeholder_summary' => 'You can use special placeholders that will be replaced by the actual values:',
|
||||||
|
'http_body_placeholder_title' => 'Placeholder',
|
||||||
|
'http_body_placeholder_description' => 'Description',
|
||||||
|
'http_body_placeholder_title_description' => 'Article title',
|
||||||
|
'http_body_placeholder_url_description' => 'HTML-encoded link of the article',
|
||||||
|
'http_body_placeholder_content_description' => 'Content of the article (HTML format)',
|
||||||
|
'http_body_placeholder_authors_description' => 'Authors of the article',
|
||||||
|
'http_body_placeholder_feed_description' => 'Feed of the article',
|
||||||
|
'http_body_placeholder_tags_description' => 'Article tags (string, separated by " #")',
|
||||||
|
'http_body_placeholder_date_description' => 'Date of the article (string)',
|
||||||
|
'http_body_placeholder_date_timestamp_description' => 'Date of the article as timestamp (number)',
|
||||||
|
'http_body_placeholder_thumbnail_url_description' => 'Thumbnail (image) URL',
|
||||||
|
'webhook_headers' => 'HTTP Headers<br />(one per line)',
|
||||||
|
'http_body_type' => 'HTTP Body type',
|
||||||
|
'more_info' => 'More info:',
|
||||||
|
'more_info_description' => 'When header contains <b><code>Content-type: application/x-www-form-urlencoded</code></b> 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'
|
||||||
|
),
|
||||||
|
);
|
||||||
8
xExtension-Webhook/metadata.json
Normal file
8
xExtension-Webhook/metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "Webhook",
|
||||||
|
"author": "Lukas Melega, Ryahn",
|
||||||
|
"description": "Send custom webhook when new article appears (and matches custom criteria)",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"entrypoint": "Webhook",
|
||||||
|
"type": "system"
|
||||||
|
}
|
||||||
309
xExtension-Webhook/request.php
Normal file
309
xExtension-Webhook/request.php
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimized HTTP request handler for Webhook extension
|
||||||
|
*
|
||||||
|
* This function sends HTTP requests with proper validation, error handling,
|
||||||
|
* and logging capabilities for the FreshRSS Webhook extension.
|
||||||
|
*
|
||||||
|
* @param string $url The target URL for the HTTP request
|
||||||
|
* @param string $method HTTP method (GET, POST, PUT, DELETE, etc.)
|
||||||
|
* @param string $bodyType Content type for the request body ('json' or 'form')
|
||||||
|
* @param string $body Request body content as JSON string
|
||||||
|
* @param string[] $headers Array of HTTP headers
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param string $additionalLog Additional context for logging
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
function sendReq(
|
||||||
|
string $url,
|
||||||
|
string $method,
|
||||||
|
string $bodyType,
|
||||||
|
string $body,
|
||||||
|
array $headers = [],
|
||||||
|
bool $logEnabled = true,
|
||||||
|
string $additionalLog = "",
|
||||||
|
): void {
|
||||||
|
// Validate inputs
|
||||||
|
if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
throw new InvalidArgumentException("Invalid URL provided: {$url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
|
||||||
|
if (!in_array(strtoupper($method), $allowedMethods, true)) {
|
||||||
|
throw new InvalidArgumentException("Invalid HTTP method: {$method}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedBodyTypes = ['json', 'form'];
|
||||||
|
if (!in_array($bodyType, $allowedBodyTypes, true)) {
|
||||||
|
throw new InvalidArgumentException("Invalid body type: {$bodyType}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
throw new RuntimeException("Failed to initialize cURL session");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Configure HTTP method
|
||||||
|
configureHttpMethod($ch, strtoupper($method));
|
||||||
|
|
||||||
|
// Process and set HTTP body
|
||||||
|
$processedBody = processHttpBody($body, $bodyType, $method, $logEnabled);
|
||||||
|
if ($processedBody !== null && $method !== '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);
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
executeRequest($ch, $logEnabled);
|
||||||
|
|
||||||
|
} catch (Throwable $err) {
|
||||||
|
logError($logEnabled, "Error in sendReq: {$err->getMessage()} | URL: {$url} | Body: {$body}");
|
||||||
|
throw $err;
|
||||||
|
} finally {
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure cURL HTTP method settings
|
||||||
|
*
|
||||||
|
* Sets the appropriate cURL options based on the HTTP method.
|
||||||
|
*
|
||||||
|
* @param CurlHandle $ch The cURL handle
|
||||||
|
* @param string $method HTTP method in uppercase
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function configureHttpMethod(CurlHandle $ch, string $method): void {
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'POST':
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
break;
|
||||||
|
case 'PUT':
|
||||||
|
curl_setopt($ch, CURLOPT_PUT, true);
|
||||||
|
break;
|
||||||
|
case 'GET':
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
|
||||||
|
break;
|
||||||
|
case 'DELETE':
|
||||||
|
case 'PATCH':
|
||||||
|
case 'OPTIONS':
|
||||||
|
case 'HEAD':
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process HTTP body based on content type
|
||||||
|
*
|
||||||
|
* Converts the request body to the appropriate format based on the body type.
|
||||||
|
* Supports JSON and form-encoded data.
|
||||||
|
*
|
||||||
|
* @param string $body Raw body content as JSON string
|
||||||
|
* @param string $bodyType Content type ('json' or 'form')
|
||||||
|
* @param string $method HTTP method
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
*
|
||||||
|
* @throws JsonException When JSON processing fails
|
||||||
|
* @throws InvalidArgumentException When unsupported body type is provided
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return string|null Processed body content or null if no body needed
|
||||||
|
*/
|
||||||
|
function processHttpBody(string $body, string $bodyType, string $method, bool $logEnabled): ?string {
|
||||||
|
if (empty($body) || $method === 'GET') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bodyObject = json_decode($body, true, 256, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return match ($bodyType) {
|
||||||
|
'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR),
|
||||||
|
'form' => http_build_query($bodyObject ?? []),
|
||||||
|
default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}")
|
||||||
|
};
|
||||||
|
} catch (JsonException $err) {
|
||||||
|
logError($logEnabled, "JSON processing error: {$err->getMessage()} | Body: {$body}");
|
||||||
|
throw $err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure HTTP headers for the request
|
||||||
|
*
|
||||||
|
* Sets appropriate Content-Type headers if none are provided,
|
||||||
|
* based on the body type.
|
||||||
|
*
|
||||||
|
* @param string[] $headers Array of custom headers
|
||||||
|
* @param string $bodyType Content type ('json' or 'form')
|
||||||
|
*
|
||||||
|
* @return string[] Final array of headers to use
|
||||||
|
*/
|
||||||
|
function configureHeaders(array $headers, string $bodyType): array {
|
||||||
|
if (empty($headers)) {
|
||||||
|
return match ($bodyType) {
|
||||||
|
'form' => ['Content-Type: application/x-www-form-urlencoded'],
|
||||||
|
'json' => ['Content-Type: application/json'],
|
||||||
|
default => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the outgoing HTTP request details
|
||||||
|
*
|
||||||
|
* Logs comprehensive information about the request being sent,
|
||||||
|
* including URL, method, body, and headers.
|
||||||
|
*
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param string $additionalLog Additional context information
|
||||||
|
* @param string $method HTTP method
|
||||||
|
* @param string $url Target URL
|
||||||
|
* @param string $bodyType Content type
|
||||||
|
* @param string|null $body Processed request body
|
||||||
|
* @param string[] $headers Array of HTTP headers
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function logRequest(
|
||||||
|
bool $logEnabled,
|
||||||
|
string $additionalLog,
|
||||||
|
string $method,
|
||||||
|
string $url,
|
||||||
|
string $bodyType,
|
||||||
|
?string $body,
|
||||||
|
array $headers
|
||||||
|
): void {
|
||||||
|
if (!$logEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanUrl = urldecode($url);
|
||||||
|
$cleanBody = $body ? str_replace('\/', '/', $body) : '';
|
||||||
|
$headersJson = json_encode($headers);
|
||||||
|
|
||||||
|
$logMessage = trim("{$additionalLog} ♦♦ sendReq ⏩ {$method}: {$cleanUrl} ♦♦ {$bodyType} ♦♦ {$cleanBody} ♦♦ {$headersJson}");
|
||||||
|
|
||||||
|
logWarning($logEnabled, $logMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute cURL request and handle response
|
||||||
|
*
|
||||||
|
* Executes the configured cURL request and handles both success
|
||||||
|
* and error responses with appropriate logging.
|
||||||
|
*
|
||||||
|
* @param CurlHandle $ch The configured cURL handle
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
*
|
||||||
|
* @throws RuntimeException When cURL execution fails
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function executeRequest(CurlHandle $ch, bool $logEnabled): void {
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
$error = curl_error($ch);
|
||||||
|
logError($logEnabled, "cURL error: {$error}");
|
||||||
|
throw new RuntimeException("cURL error: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = curl_getinfo($ch);
|
||||||
|
$httpCode = $info['http_code'] ?? 'unknown';
|
||||||
|
|
||||||
|
logWarning($logEnabled, "Response ✅ ({$httpCode}) {$response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log warning message using FreshRSS logging system
|
||||||
|
*
|
||||||
|
* Safely logs warning messages through the FreshRSS Minz_Log system
|
||||||
|
* with proper class existence checking.
|
||||||
|
*
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log (will be converted to string)
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function logWarning(bool $logEnabled, $data): void {
|
||||||
|
if ($logEnabled && class_exists('Minz_Log')) {
|
||||||
|
Minz_Log::warning("[WEBHOOK] " . $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error message using FreshRSS logging system
|
||||||
|
*
|
||||||
|
* Safely logs error messages through the FreshRSS Minz_Log system
|
||||||
|
* with proper class existence checking.
|
||||||
|
*
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log (will be converted to string)
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function logError(bool $logEnabled, $data): void {
|
||||||
|
if ($logEnabled && class_exists('Minz_Log')) {
|
||||||
|
Minz_Log::error("[WEBHOOK]❌ " . $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility alias for logWarning function
|
||||||
|
*
|
||||||
|
* @deprecated Use logWarning() instead
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function LOG_WARN(bool $logEnabled, $data): void {
|
||||||
|
logWarning($logEnabled, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility alias for logError function
|
||||||
|
*
|
||||||
|
* @deprecated Use logError() instead
|
||||||
|
* @param bool $logEnabled Whether logging is enabled
|
||||||
|
* @param mixed $data Data to log
|
||||||
|
*
|
||||||
|
* @throws Minz_PermissionDeniedException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function LOG_ERR(bool $logEnabled, $data): void {
|
||||||
|
logError($logEnabled, $data);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue