Skip to content

Fluxer support#1536

Open
caronc wants to merge 4 commits intomasterfrom
fluxer-support
Open

Fluxer support#1536
caronc wants to merge 4 commits intomasterfrom
fluxer-support

Conversation

@caronc
Copy link
Owner

@caronc caronc commented Mar 4, 2026

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.

  • Supports Fluxer Cloud mode, and private/self-hosted mode.
  • Supports embedded payload options such as footer, footer logo, and inline image.
  • Supports pinging users and roles through a ping= URL parameter.
  • Supports posting into a thread, optionally naming the thread.
  • Supports attachments

Fluxer Notifications

  • Source: https://fluxer.app
  • Image Support: Yes
  • Attachment Support: Yes
  • Message Format: Plain Text is the default, optional Markdown support
  • Message Limit: 2000 characters

Account Setup

  1. Sign in to Fluxer and create an incoming webhook.
  2. Copy the webhook URL, it will look like:
    https://api.fluxer.app/webhooks/{WebhookID}/{WebhookToken}
  3. Extract the two tokens:
    • WebhookID: the numeric portion in the URL path
    • WebhookToken: the long token portion in the URL path
  4. Use either the native Fluxer URL directly, or convert it to an Apprise URL.

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 to https://api.fluxer.app
  • mode=private: posts to the host you specify in the URL

Examples:

  • fluxer://fluxer.example.com/{WebhookID}/{WebhookToken}/?mode=private
  • fluxer://fluxer.example.com:443/{WebhookID}/{WebhookToken}/?mode=private

If mode=private is selected but the host is api.fluxer.app (or contains
fluxer.app), Apprise falls back to mode=cloud.

Parameter Breakdown

Variable Required Description
WebhookID Yes The first part of the tokens provided after creating the webhook
WebhookToken Yes The second part of the tokens provided after creating the webhook
botname No Bot username displayed for the notification
host No Private Fluxer host, used when mode=private
port No Private Fluxer port, used when mode=private
mode No One of: cloud (default), private
tts No Enable Text To Speech (default: No)
avatar No Use Apprise notification-type avatar (default: Yes)
avatar_url No Override avatar URL
href No A URL to associate with the message where supported
url No Alias of href
footer No Include a footer in embeds (default: No)
footer_logo No Include footer logo when footer=yes (default: Yes)
image No Include an inline image describing notification type (default: No)
fields No Use embedded fields for markdown parsing (default: Yes)
flags No Discord-style flags integer (default: 0)
ping No Comma-separated list of mentions to include, users, roles, everyone, here
thread No Thread ID to post into
thread_name No Thread name to use with thread=

New Service Completion Status

  • apprise/plugins/fluxer.py
  • pyproject.toml
    • Update keywords section to identify the new service (alphabetically).
  • README.md
    • Add entry for the new service (quick reference only).
  • packaging/redhat/python-apprise.spec
    • Add new service into the %global common_description

Checklist

  • Documentation ticket created (if applicable): apprise-docs/16
  • The change is tested and works locally.
  • No commented-out code in this PR.
  • No lint errors (use tox -e lint and optionally tox -e format).
  • Test coverage added or updated (use tox -e minimal).

Testing

Anyone can help test as follows:

# Create a virtual environment
python3 -m venv apprise

# Change into our new directory
cd apprise

# Activate our virtual environment
source bin/activate

# Install the branch
pip install git+https://github.com/caronc/apprise.git@fluxer-support

# Basic message test
apprise -vv -t "Test Title" -b "Test Message" \
  "fluxer://{WebhookID}/{WebhookToken}"

# Attachment test
apprise -vv -b "Test Attachment" \
  --attach=/path/to/file.png \
  "fluxer://{WebhookID}/{WebhookToken}"

# Optional markdown formatting test
cat << _EOF | apprise -vv \
  "fluxer://{WebhookID}/{WebhookToken}?format=markdown"
# Fluxer Markdown Test
- Works
- Great
_EOF

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NotifyFluxer plugin 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.toml keywords 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,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"wait": False,
"wait": True,

Copilot uses AI. Check for mistakes.
Comment on lines +456 to +477
# 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,
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +740 to +751
# 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
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +1276 to +1284
# 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, {}),
]
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
`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` \
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
`Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer` \
`Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer`, \

Copilot uses AI. Check for mistakes.
Comment on lines +694 to +700
# 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,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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,

Copilot uses AI. Check for mistakes.
# Include Image
self.include_image = include_image if isinstance(include_image, bool) \
else parse_bool(
include_image, self.template_args["include_image"]["default"])
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
include_image, self.template_args["include_image"]["default"])
include_image, self.template_args["image"]["default"])

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Mar 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (afdc32c) to head (e92a641).

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     
Files with missing lines Coverage Δ
apprise/plugins/fluxer.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants