Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Apprise notification plugin for Fluxer (Discord-compatible webhooks), including URL parsing, message formatting options (plain text vs markdown embeds), attachment uploads, ping handling, and basic 429 rate-limit behavior, plus packaging/test updates.
Changes:
- Introduce
NotifyFluxerplugin implementing Fluxer webhook delivery (cloud/private modes, markdown/embed options, pings, threads, attachments, 429 handling). - Add comprehensive unit tests covering URL parsing, payload construction, mention parsing, overflow splitting, attachments, and rate-limiting paths.
- Register Fluxer in packaging metadata (
pyproject.tomlkeywords and the RedHat spec description list).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
apprise/plugins/fluxer.py |
New Fluxer notification plugin (webhook URL support, formatting/pings/threads, attachments, 429 handling). |
tests/test_plugin_fluxer.py |
New Fluxer-focused test suite (URLs, payload behavior, rate limiting, attachments). |
pyproject.toml |
Adds “Fluxer” to package keywords. |
packaging/redhat/python-apprise.spec |
Adds Fluxer to the list of supported services in the RPM description. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Text-To-Speech | ||
| "tts": False, | ||
| # Wait until the upload has posted itself before continuing | ||
| "wait": False, |
There was a problem hiding this comment.
In the attachment branch, the comment says we should wait for the upload to post before continuing, but wait is set to False. This differs from the Discord plugin behavior (which sets wait=True for attachments) and may cause inconsistent delivery semantics. Either flip the value or update the comment to match the intended behavior.
| "wait": False, | |
| "wait": True, |
| # Turn off clock skew for local testing | ||
| NotifyFluxer.clock_skew = timedelta(seconds=0) | ||
|
|
||
| webhook_id, webhook_token = _tokens() | ||
|
|
||
| # Basic construction checks (keep these, they match plugin validation) | ||
| with pytest.raises(TypeError): | ||
| NotifyFluxer(webhook_id=None, webhook_token=webhook_token) | ||
| with pytest.raises(TypeError): | ||
| NotifyFluxer(webhook_id=" ", webhook_token=webhook_token) | ||
|
|
||
| with pytest.raises(TypeError): | ||
| NotifyFluxer(webhook_id=webhook_id, webhook_token=None) | ||
| with pytest.raises(TypeError): | ||
| NotifyFluxer(webhook_id=webhook_id, webhook_token=" ") | ||
|
|
||
| obj = NotifyFluxer( | ||
| webhook_id=webhook_id, | ||
| webhook_token=webhook_token, | ||
| footer=True, | ||
| include_image=True, | ||
| ) |
There was a problem hiding this comment.
This test mutates the global/class setting NotifyFluxer.clock_skew but never restores it. Since tests can run in any order, this can leak state into other tests and cause flakiness; please restore the original value at the end (or use a fixture/context manager).
| # Turn off clock skew for local testing | |
| NotifyFluxer.clock_skew = timedelta(seconds=0) | |
| webhook_id, webhook_token = _tokens() | |
| # Basic construction checks (keep these, they match plugin validation) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=None, webhook_token=webhook_token) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=" ", webhook_token=webhook_token) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=webhook_id, webhook_token=None) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=webhook_id, webhook_token=" ") | |
| obj = NotifyFluxer( | |
| webhook_id=webhook_id, | |
| webhook_token=webhook_token, | |
| footer=True, | |
| include_image=True, | |
| ) | |
| # Preserve original clock skew so test does not leak global state | |
| original_clock_skew = NotifyFluxer.clock_skew | |
| try: | |
| # Turn off clock skew for local testing | |
| NotifyFluxer.clock_skew = timedelta(seconds=0) | |
| webhook_id, webhook_token = _tokens() | |
| # Basic construction checks (keep these, they match plugin validation) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=None, webhook_token=webhook_token) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=" ", webhook_token=webhook_token) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=webhook_id, webhook_token=None) | |
| with pytest.raises(TypeError): | |
| NotifyFluxer(webhook_id=webhook_id, webhook_token=" ") | |
| obj = NotifyFluxer( | |
| webhook_id=webhook_id, | |
| webhook_token=webhook_token, | |
| footer=True, | |
| include_image=True, | |
| ) | |
| finally: | |
| # Restore original clock skew to avoid affecting other tests | |
| NotifyFluxer.clock_skew = original_clock_skew |
| # This call includes an image with it's payload: | ||
| NotifyFluxer.fluxer_max_fields = 1 | ||
|
|
||
| assert ( | ||
| a.notify( | ||
| body=test_markdown, | ||
| title="title", | ||
| notify_type=NotifyType.INFO, | ||
| body_format=NotifyFormat.TEXT, | ||
| ) | ||
| is True | ||
| ) |
There was a problem hiding this comment.
This test mutates the class variable NotifyFluxer.fluxer_max_fields but doesn’t restore it afterward. Because pytest doesn’t guarantee test order, this can affect later tests; please reset it (e.g., in a finally block or fixture).
| # This call includes an image with it's payload: | |
| NotifyFluxer.fluxer_max_fields = 1 | |
| assert ( | |
| a.notify( | |
| body=test_markdown, | |
| title="title", | |
| notify_type=NotifyType.INFO, | |
| body_format=NotifyFormat.TEXT, | |
| ) | |
| is True | |
| ) | |
| # This call includes an image with its payload: | |
| orig_fluxer_max_fields = NotifyFluxer.fluxer_max_fields | |
| try: | |
| NotifyFluxer.fluxer_max_fields = 1 | |
| assert ( | |
| a.notify( | |
| body=test_markdown, | |
| title="title", | |
| notify_type=NotifyType.INFO, | |
| body_format=NotifyFormat.TEXT, | |
| ) | |
| is True | |
| ) | |
| finally: | |
| # Restore the original value to avoid impacting other tests | |
| NotifyFluxer.fluxer_max_fields = orig_fluxer_max_fields |
| # 3 posts: | ||
| # (1) initial message post succeeds | ||
| # (2) attachment post 429 triggers file close-before-recursion | ||
| # (3) recursive retry succeeds (note: retry is non-attachment in baseline) | ||
| mock_post.side_effect = [ | ||
| _resp(requests.codes.no_content, {}), | ||
| _resp(requests.codes.too_many_requests, {"Retry-After": "1"}), | ||
| _resp(requests.codes.no_content, {}), | ||
| ] |
There was a problem hiding this comment.
This 429/attachment test currently assumes the recursive retry is a non-attachment request. That would mean the file is not re-uploaded after a 429, which is incorrect for attachment delivery. After fixing the plugin to retry with attach=..., update this test to assert the retry re-sends the multipart upload (e.g., by checking files is provided on the retry call).
| `Bark`, `BlueSky`, `Brevo`, `Burst SMS`, `BulkSMS`, `BulkVS`, `Chanify`, \ | ||
| `Clickatell`, `ClickSend`, `DAPNET`, `DingTalk`, `Discord`, \ | ||
| `Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, \ | ||
| `Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer` \ |
There was a problem hiding this comment.
Missing comma after Fluxer in this backtick-delimited list; as-is it reads like a single entry “Fluxer Free Mobile”. Add the comma to keep the service list formatting consistent.
| `Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer` \ | |
| `Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer`, \ |
| # Recursive retry; next _send() invocation will hit the | ||
| # ratelimit_remaining<=0 gate and sleep via throttle() | ||
| return self._send( | ||
| payload=payload, | ||
| params=params, | ||
| rate_limit=rate_limit - 1, | ||
| **kwargs, |
There was a problem hiding this comment.
On HTTP 429, the recursive retry drops the attach argument. If a rate-limit happens during an attachment upload, the retry will send a JSON payload without re-uploading the file, resulting in a false success. Pass the attachment through (and ensure the file is re-opened on retry) so the attachment upload is actually retried.
| # Recursive retry; next _send() invocation will hit the | |
| # ratelimit_remaining<=0 gate and sleep via throttle() | |
| return self._send( | |
| payload=payload, | |
| params=params, | |
| rate_limit=rate_limit - 1, | |
| **kwargs, | |
| # Ensure attachments are preserved across the recursive | |
| # retry so that file handles can be reopened on retry. | |
| retry_kwargs = dict(kwargs) | |
| try: | |
| # If attach was passed as an explicit argument to | |
| # this method, make sure it is forwarded unless an | |
| # override is already present in kwargs. | |
| if "attach" not in retry_kwargs: | |
| retry_kwargs["attach"] = attach | |
| except NameError: | |
| # If there is no local 'attach' (e.g., not in the | |
| # function signature), there is nothing extra to add. | |
| pass | |
| # Recursive retry; next _send() invocation will hit the | |
| # ratelimit_remaining<=0 gate and sleep via throttle() | |
| return self._send( | |
| payload=payload, | |
| params=params, | |
| rate_limit=rate_limit - 1, | |
| **retry_kwargs, |
| # Include Image | ||
| self.include_image = include_image if isinstance(include_image, bool) \ | ||
| else parse_bool( | ||
| include_image, self.template_args["include_image"]["default"]) |
There was a problem hiding this comment.
self.template_args does not define an include_image key (the argument is named image and maps to include_image). If include_image is provided as a non-bool (e.g., via Python API), this will raise a KeyError. Use the image template arg default (or avoid looking up template_args here) when calling parse_bool().
| include_image, self.template_args["include_image"]["default"]) | |
| include_image, self.template_args["image"]["default"]) |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1536 +/- ##
==========================================
Coverage 100.00% 100.00%
==========================================
Files 194 195 +1
Lines 25372 25693 +321
Branches 4118 4189 +71
==========================================
+ Hits 25372 25693 +321
🚀 New features to boost your workflow:
|
Description
Related issue (if applicable): n/a
Fluxer support added
Fluxer is a webhook-driven service with a Discord-compatible payload model. The
implementation supports plain text posting by default, optional markdown-to-embed
formatting, optional avatar selection, attachment uploads, and rate-limit aware
delivery with HTTP 429 handling.
ping=URL parameter.Fluxer Notifications
Account Setup
https://api.fluxer.app/webhooks/{WebhookID}/{WebhookToken}Syntax
Valid syntax is as follows:
https://api.fluxer.app/webhooks/{WebhookID}/{WebhookToken}https://api.fluxer.app/v1/webhooks/{WebhookID}/{WebhookToken}fluxer://{WebhookID}/{WebhookToken}/fluxer://{botname}@{WebhookID}/{WebhookToken}/Private Server Mode
Fluxer supports a private mode for self-hosted deployments.
mode=cloud(default): posts tohttps://api.fluxer.appmode=private: posts to the host you specify in the URLExamples:
fluxer://fluxer.example.com/{WebhookID}/{WebhookToken}/?mode=privatefluxer://fluxer.example.com:443/{WebhookID}/{WebhookToken}/?mode=privateIf
mode=privateis selected but the host isapi.fluxer.app(or containsfluxer.app), Apprise falls back tomode=cloud.Parameter Breakdown
mode=privatemode=privatecloud(default),privatehreffooter=yes(default: Yes)everyone,herethread=New Service Completion Status
%global common_descriptionChecklist
tox -e lintand optionallytox -e format).tox -e minimal).Testing
Anyone can help test as follows: