feat: fluxer notification provider (#7109)

This commit is contained in:
riccardo
2026-03-07 19:55:46 +01:00
committed by GitHub
parent c80e3cfb9d
commit c60210124a
6 changed files with 351 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { DOWN, UP } = require("../../src/util");
class Fluxer extends NotificationProvider {
name = "fluxer";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
const fluxerDisplayName = notification.fluxerUsername || "Uptime Kuma";
const webhookUrl = new URL(notification.fluxerWebhookUrl);
// Check if the webhook has an avatar
let webhookHasAvatar = true;
try {
const webhookInfo = await axios.get(webhookUrl.toString(), config);
webhookHasAvatar = !!webhookInfo.data.avatar;
} catch (e) {
// If we can't verify, we assume he has an avatar to avoid forcing the default avatar
webhookHasAvatar = true;
}
const messageFormat =
notification.fluxerMessageFormat || (notification.fluxerUseMessageTemplate ? "custom" : "normal");
// If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) {
let content = msg;
if (messageFormat === "minimalist") {
content = "Test: " + msg;
} else if (messageFormat === "custom") {
const customMessage = notification.fluxerMessageTemplate?.trim() || "";
if (customMessage !== "") {
content = await this.renderTemplate(customMessage, msg, monitorJSON, heartbeatJSON);
}
}
let fluxertestdata = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
fluxertestdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
await axios.post(webhookUrl.toString(), fluxertestdata, config);
return okMsg;
}
// If heartbeatJSON is not null, we go into the normal alerting loop.
let addess = this.extractAddress(monitorJSON);
// Minimalist: status + name only (is down / is up; no "back up" — may be first trigger)
if (messageFormat === "minimalist") {
const content =
heartbeatJSON["status"] === DOWN
? "🔴 " + monitorJSON["name"] + " is down."
: "🟢 " + monitorJSON["name"] + " is up.";
let payload = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
await axios.post(webhookUrl.toString(), payload, config);
return okMsg;
}
// Custom template: send only content (no embeds)
const useCustomTemplate =
messageFormat === "custom" && (notification.fluxerMessageTemplate?.trim() || "") !== "";
if (useCustomTemplate) {
const content = await this.renderTemplate(
notification.fluxerMessageTemplate.trim(),
msg,
monitorJSON,
heartbeatJSON
);
let payload = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
await axios.post(webhookUrl.toString(), payload, config);
return okMsg;
}
if (heartbeatJSON["status"] === DOWN) {
const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
let fluxerdowndata = {
username: fluxerDisplayName,
embeds: [
{
title: "❌ Your service " + monitorJSON["name"] + " went down. ❌",
color: 16711680,
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
...(!notification.disableUrl && addess
? [
{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: addess,
},
]
: []),
{
name: "Went Offline",
// F for full date/time
value: `<t:${wentOfflineTimestamp}:F>`,
},
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
},
{
name: "Error",
value: heartbeatJSON["msg"] == null ? "N/A" : heartbeatJSON["msg"],
},
],
},
],
};
if (!webhookHasAvatar) {
fluxerdowndata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.fluxerPrefixMessage) {
fluxerdowndata.content = notification.fluxerPrefixMessage;
}
await axios.post(webhookUrl.toString(), fluxerdowndata, config);
return okMsg;
} else if (heartbeatJSON["status"] === UP) {
const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
let downtimeDuration = null;
let wentOfflineTimestamp = null;
if (heartbeatJSON["lastDownTime"]) {
wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000);
downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp);
}
let fluxerupdata = {
username: fluxerDisplayName,
embeds: [
{
title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
color: 65280,
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
...(!notification.disableUrl && addess
? [
{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: addess,
},
]
: []),
...(wentOfflineTimestamp
? [
{
name: "Went Offline",
// F for full date/time
value: `<t:${wentOfflineTimestamp}:F>`,
},
]
: []),
...(downtimeDuration
? [
{
name: "Downtime Duration",
value: downtimeDuration,
},
]
: []),
// Show server timezone for parity with the DOWN notification embed
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
},
...(heartbeatJSON["ping"] != null
? [
{
name: "Ping",
value: heartbeatJSON["ping"] + " ms",
},
]
: []),
],
},
],
};
if (!webhookHasAvatar) {
fluxerupdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.fluxerPrefixMessage) {
fluxerupdata.content = notification.fluxerPrefixMessage;
}
await axios.post(webhookUrl.toString(), fluxerupdata, config);
return okMsg;
}
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
/**
* Format duration as human-readable string (e.g., "1h 23m", "45m 30s")
* TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version
* @param {number} timeInSeconds The time in seconds to format a duration for
* @returns {string} The formatted duration
*/
formatDuration(timeInSeconds) {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = timeInSeconds % 60;
const durationParts = [];
if (hours > 0) {
durationParts.push(`${hours}h`);
}
if (minutes > 0) {
durationParts.push(`${minutes}m`);
}
if (seconds > 0 && hours === 0) {
// Only show seconds if less than an hour
durationParts.push(`${seconds}s`);
}
return durationParts.length > 0 ? durationParts.join(" ") : "0s";
}
}
module.exports = Fluxer;
+2
View File
@@ -12,6 +12,7 @@ const CallMeBot = require("./notification-providers/call-me-bot");
const SMSC = require("./notification-providers/smsc");
const DingDing = require("./notification-providers/dingding");
const Discord = require("./notification-providers/discord");
const Fluxer = require("./notification-providers/fluxer");
const Elks = require("./notification-providers/46elks");
const Feishu = require("./notification-providers/feishu");
const Notifery = require("./notification-providers/notifery");
@@ -117,6 +118,7 @@ class Notification {
new SMSC(),
new DingDing(),
new Discord(),
new Fluxer(),
new Elks(),
new Feishu(),
new FreeMobile(),
+1
View File
@@ -215,6 +215,7 @@ export default {
bale: "Bale",
Bitrix24: "Bitrix24",
discord: "Discord",
fluxer: "Fluxer",
GoogleChat: "Google Chat (Google Workspace)",
gorush: "Gorush",
gotify: "Gotify",
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="mb-3">
<label for="fluxer-webhook-url" class="form-label">{{ $t("Fluxer Webhook URL") }}</label>
<HiddenInput
id="fluxer-webhook-url"
v-model="$parent.notification.fluxerWebhookUrl"
type="url"
class="form-control"
required
autocomplete="false"
/>
<div class="form-text">
{{ $t("wayToGetFluxerURL") }}
</div>
</div>
<div class="mb-3">
<label for="fluxer-username" class="form-label">{{ $t("Bot Display Name") }}</label>
<input
id="fluxer-username"
v-model="$parent.notification.fluxerUsername"
type="text"
class="form-control"
autocomplete="false"
:placeholder="$root.appName"
/>
</div>
<div class="mb-3">
<label for="fluxer-prefix-message" class="form-label">{{ $t("Prefix Custom Message") }}</label>
<input
id="fluxer-prefix-message"
v-model="$parent.notification.fluxerPrefixMessage"
type="text"
class="form-control"
autocomplete="false"
:placeholder="$t('Hello @everyone is...')"
/>
</div>
<div class="mb-3">
<label for="fluxer-message-format" class="form-label">{{ $t("fluxerMessageFormat") }}</label>
<select id="fluxer-message-format" v-model="$parent.notification.fluxerMessageFormat" class="form-select">
<option value="normal">{{ $t("fluxerMessageFormatNormal") }}</option>
<option value="minimalist">{{ $t("fluxerMessageFormatMinimalist") }}</option>
<option value="custom">{{ $t("fluxerMessageFormatCustom") }}</option>
</select>
</div>
<div v-show="$parent.notification.fluxerMessageFormat === 'custom'">
<div class="mb-3">
<label for="fluxer-message-template" class="form-label">{{ $t("fluxerMessageTemplate") }}</label>
<TemplatedTextarea
id="fluxer-message-template"
v-model="$parent.notification.fluxerMessageTemplate"
:required="false"
placeholder=""
></TemplatedTextarea>
<div class="form-text">{{ $t("fluxerUseMessageTemplateDescription") }}</div>
</div>
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
import TemplatedTextarea from "../TemplatedTextarea.vue";
export default {
components: {
TemplatedTextarea,
HiddenInput,
},
mounted() {
if (!this.$parent.notification.fluxerChannelType) {
this.$parent.notification.fluxerChannelType = "channel";
}
if (this.$parent.notification.disableUrl === undefined) {
this.$parent.notification.disableUrl = false;
}
// Message format: default "normal"; migrate from old checkbox
if (typeof this.$parent.notification.fluxerMessageFormat === "undefined") {
const hadCustom =
this.$parent.notification.fluxerUseMessageTemplate === true ||
!!this.$parent.notification.fluxerMessageTemplate?.trim();
this.$parent.notification.fluxerMessageFormat = hadCustom ? "custom" : "normal";
}
},
};
</script>
+2
View File
@@ -11,6 +11,7 @@ import CallMeBot from "./CallMeBot.vue";
import SMSC from "./SMSC.vue";
import DingDing from "./DingDing.vue";
import Discord from "./Discord.vue";
import Fluxer from "./Fluxer.vue";
import Elks from "./46elks.vue";
import Feishu from "./Feishu.vue";
import FreeMobile from "./FreeMobile.vue";
@@ -105,6 +106,7 @@ const NotificationFormList = {
smsir: SMSIR,
DingDing: DingDing,
discord: Discord,
fluxer: Fluxer,
Elks: Elks,
Feishu: Feishu,
FreeMobile: FreeMobile,
+9
View File
@@ -1358,6 +1358,15 @@
"discordUseMessageTemplate": "Use custom message template",
"discordUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
"discordMessageTemplate": "Message Template",
"fluxerMessageFormat": "Message Format",
"fluxerMessageFormatNormal": "Normal (rich embeds)",
"fluxerMessageFormatMinimalist": "Minimalist (short status)",
"fluxerMessageFormatCustom": "Custom template",
"fluxerUseMessageTemplate": "Use custom message template",
"fluxerUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
"fluxerMessageTemplate": "Message Template",
"Fluxer Webhook URL": "Fluxer Webhook URL",
"wayToGetFluxerURL": "You can get this by going to the target channel's settings > Webhooks > Create Webhook > Copy Webhook URL.",
"Ip Family": "IP Family",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",