Skip to content

Commit c602101

Browse files
authored
feat: fluxer notification provider (#7109)
1 parent c80e3cf commit c602101

File tree

6 files changed

+351
-0
lines changed

6 files changed

+351
-0
lines changed
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
const NotificationProvider = require("./notification-provider");
2+
const axios = require("axios");
3+
const { DOWN, UP } = require("../../src/util");
4+
5+
class Fluxer extends NotificationProvider {
6+
name = "fluxer";
7+
8+
/**
9+
* @inheritdoc
10+
*/
11+
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
12+
const okMsg = "Sent Successfully.";
13+
14+
try {
15+
let config = this.getAxiosConfigWithProxy({});
16+
const fluxerDisplayName = notification.fluxerUsername || "Uptime Kuma";
17+
const webhookUrl = new URL(notification.fluxerWebhookUrl);
18+
19+
// Check if the webhook has an avatar
20+
let webhookHasAvatar = true;
21+
try {
22+
const webhookInfo = await axios.get(webhookUrl.toString(), config);
23+
webhookHasAvatar = !!webhookInfo.data.avatar;
24+
} catch (e) {
25+
// If we can't verify, we assume he has an avatar to avoid forcing the default avatar
26+
webhookHasAvatar = true;
27+
}
28+
29+
const messageFormat =
30+
notification.fluxerMessageFormat || (notification.fluxerUseMessageTemplate ? "custom" : "normal");
31+
32+
// If heartbeatJSON is null, assume we're testing.
33+
if (heartbeatJSON == null) {
34+
let content = msg;
35+
if (messageFormat === "minimalist") {
36+
content = "Test: " + msg;
37+
} else if (messageFormat === "custom") {
38+
const customMessage = notification.fluxerMessageTemplate?.trim() || "";
39+
if (customMessage !== "") {
40+
content = await this.renderTemplate(customMessage, msg, monitorJSON, heartbeatJSON);
41+
}
42+
}
43+
let fluxertestdata = {
44+
username: fluxerDisplayName,
45+
content: content,
46+
};
47+
if (!webhookHasAvatar) {
48+
fluxertestdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
49+
}
50+
await axios.post(webhookUrl.toString(), fluxertestdata, config);
51+
return okMsg;
52+
}
53+
54+
// If heartbeatJSON is not null, we go into the normal alerting loop.
55+
let addess = this.extractAddress(monitorJSON);
56+
57+
// Minimalist: status + name only (is down / is up; no "back up" — may be first trigger)
58+
if (messageFormat === "minimalist") {
59+
const content =
60+
heartbeatJSON["status"] === DOWN
61+
? "🔴 " + monitorJSON["name"] + " is down."
62+
: "🟢 " + monitorJSON["name"] + " is up.";
63+
let payload = {
64+
username: fluxerDisplayName,
65+
content: content,
66+
};
67+
if (!webhookHasAvatar) {
68+
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
69+
}
70+
71+
await axios.post(webhookUrl.toString(), payload, config);
72+
return okMsg;
73+
}
74+
75+
// Custom template: send only content (no embeds)
76+
const useCustomTemplate =
77+
messageFormat === "custom" && (notification.fluxerMessageTemplate?.trim() || "") !== "";
78+
if (useCustomTemplate) {
79+
const content = await this.renderTemplate(
80+
notification.fluxerMessageTemplate.trim(),
81+
msg,
82+
monitorJSON,
83+
heartbeatJSON
84+
);
85+
let payload = {
86+
username: fluxerDisplayName,
87+
content: content,
88+
};
89+
if (!webhookHasAvatar) {
90+
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
91+
}
92+
93+
await axios.post(webhookUrl.toString(), payload, config);
94+
return okMsg;
95+
}
96+
97+
if (heartbeatJSON["status"] === DOWN) {
98+
const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
99+
100+
let fluxerdowndata = {
101+
username: fluxerDisplayName,
102+
embeds: [
103+
{
104+
title: "❌ Your service " + monitorJSON["name"] + " went down. ❌",
105+
color: 16711680,
106+
fields: [
107+
{
108+
name: "Service Name",
109+
value: monitorJSON["name"],
110+
},
111+
...(!notification.disableUrl && addess
112+
? [
113+
{
114+
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
115+
value: addess,
116+
},
117+
]
118+
: []),
119+
{
120+
name: "Went Offline",
121+
// F for full date/time
122+
value: `<t:${wentOfflineTimestamp}:F>`,
123+
},
124+
{
125+
name: `Time (${heartbeatJSON["timezone"]})`,
126+
value: heartbeatJSON["localDateTime"],
127+
},
128+
{
129+
name: "Error",
130+
value: heartbeatJSON["msg"] == null ? "N/A" : heartbeatJSON["msg"],
131+
},
132+
],
133+
},
134+
],
135+
};
136+
if (!webhookHasAvatar) {
137+
fluxerdowndata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
138+
}
139+
if (notification.fluxerPrefixMessage) {
140+
fluxerdowndata.content = notification.fluxerPrefixMessage;
141+
}
142+
143+
await axios.post(webhookUrl.toString(), fluxerdowndata, config);
144+
return okMsg;
145+
} else if (heartbeatJSON["status"] === UP) {
146+
const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
147+
let downtimeDuration = null;
148+
let wentOfflineTimestamp = null;
149+
if (heartbeatJSON["lastDownTime"]) {
150+
wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000);
151+
downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp);
152+
}
153+
154+
let fluxerupdata = {
155+
username: fluxerDisplayName,
156+
embeds: [
157+
{
158+
title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
159+
color: 65280,
160+
fields: [
161+
{
162+
name: "Service Name",
163+
value: monitorJSON["name"],
164+
},
165+
...(!notification.disableUrl && addess
166+
? [
167+
{
168+
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
169+
value: addess,
170+
},
171+
]
172+
: []),
173+
...(wentOfflineTimestamp
174+
? [
175+
{
176+
name: "Went Offline",
177+
// F for full date/time
178+
value: `<t:${wentOfflineTimestamp}:F>`,
179+
},
180+
]
181+
: []),
182+
...(downtimeDuration
183+
? [
184+
{
185+
name: "Downtime Duration",
186+
value: downtimeDuration,
187+
},
188+
]
189+
: []),
190+
// Show server timezone for parity with the DOWN notification embed
191+
{
192+
name: `Time (${heartbeatJSON["timezone"]})`,
193+
value: heartbeatJSON["localDateTime"],
194+
},
195+
...(heartbeatJSON["ping"] != null
196+
? [
197+
{
198+
name: "Ping",
199+
value: heartbeatJSON["ping"] + " ms",
200+
},
201+
]
202+
: []),
203+
],
204+
},
205+
],
206+
};
207+
if (!webhookHasAvatar) {
208+
fluxerupdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
209+
}
210+
if (notification.fluxerPrefixMessage) {
211+
fluxerupdata.content = notification.fluxerPrefixMessage;
212+
}
213+
214+
await axios.post(webhookUrl.toString(), fluxerupdata, config);
215+
return okMsg;
216+
}
217+
} catch (error) {
218+
this.throwGeneralAxiosError(error);
219+
}
220+
}
221+
222+
/**
223+
* Format duration as human-readable string (e.g., "1h 23m", "45m 30s")
224+
* TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version
225+
* @param {number} timeInSeconds The time in seconds to format a duration for
226+
* @returns {string} The formatted duration
227+
*/
228+
formatDuration(timeInSeconds) {
229+
const hours = Math.floor(timeInSeconds / 3600);
230+
const minutes = Math.floor((timeInSeconds % 3600) / 60);
231+
const seconds = timeInSeconds % 60;
232+
233+
const durationParts = [];
234+
if (hours > 0) {
235+
durationParts.push(`${hours}h`);
236+
}
237+
if (minutes > 0) {
238+
durationParts.push(`${minutes}m`);
239+
}
240+
if (seconds > 0 && hours === 0) {
241+
// Only show seconds if less than an hour
242+
durationParts.push(`${seconds}s`);
243+
}
244+
245+
return durationParts.length > 0 ? durationParts.join(" ") : "0s";
246+
}
247+
}
248+
249+
module.exports = Fluxer;

server/notification.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const CallMeBot = require("./notification-providers/call-me-bot");
1212
const SMSC = require("./notification-providers/smsc");
1313
const DingDing = require("./notification-providers/dingding");
1414
const Discord = require("./notification-providers/discord");
15+
const Fluxer = require("./notification-providers/fluxer");
1516
const Elks = require("./notification-providers/46elks");
1617
const Feishu = require("./notification-providers/feishu");
1718
const Notifery = require("./notification-providers/notifery");
@@ -117,6 +118,7 @@ class Notification {
117118
new SMSC(),
118119
new DingDing(),
119120
new Discord(),
121+
new Fluxer(),
120122
new Elks(),
121123
new Feishu(),
122124
new FreeMobile(),

src/components/NotificationDialog.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export default {
215215
bale: "Bale",
216216
Bitrix24: "Bitrix24",
217217
discord: "Discord",
218+
fluxer: "Fluxer",
218219
GoogleChat: "Google Chat (Google Workspace)",
219220
gorush: "Gorush",
220221
gotify: "Gotify",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<template>
2+
<div class="mb-3">
3+
<label for="fluxer-webhook-url" class="form-label">{{ $t("Fluxer Webhook URL") }}</label>
4+
<HiddenInput
5+
id="fluxer-webhook-url"
6+
v-model="$parent.notification.fluxerWebhookUrl"
7+
type="url"
8+
class="form-control"
9+
required
10+
autocomplete="false"
11+
/>
12+
<div class="form-text">
13+
{{ $t("wayToGetFluxerURL") }}
14+
</div>
15+
</div>
16+
17+
<div class="mb-3">
18+
<label for="fluxer-username" class="form-label">{{ $t("Bot Display Name") }}</label>
19+
<input
20+
id="fluxer-username"
21+
v-model="$parent.notification.fluxerUsername"
22+
type="text"
23+
class="form-control"
24+
autocomplete="false"
25+
:placeholder="$root.appName"
26+
/>
27+
</div>
28+
29+
<div class="mb-3">
30+
<label for="fluxer-prefix-message" class="form-label">{{ $t("Prefix Custom Message") }}</label>
31+
<input
32+
id="fluxer-prefix-message"
33+
v-model="$parent.notification.fluxerPrefixMessage"
34+
type="text"
35+
class="form-control"
36+
autocomplete="false"
37+
:placeholder="$t('Hello @everyone is...')"
38+
/>
39+
</div>
40+
41+
<div class="mb-3">
42+
<label for="fluxer-message-format" class="form-label">{{ $t("fluxerMessageFormat") }}</label>
43+
<select id="fluxer-message-format" v-model="$parent.notification.fluxerMessageFormat" class="form-select">
44+
<option value="normal">{{ $t("fluxerMessageFormatNormal") }}</option>
45+
<option value="minimalist">{{ $t("fluxerMessageFormatMinimalist") }}</option>
46+
<option value="custom">{{ $t("fluxerMessageFormatCustom") }}</option>
47+
</select>
48+
</div>
49+
50+
<div v-show="$parent.notification.fluxerMessageFormat === 'custom'">
51+
<div class="mb-3">
52+
<label for="fluxer-message-template" class="form-label">{{ $t("fluxerMessageTemplate") }}</label>
53+
<TemplatedTextarea
54+
id="fluxer-message-template"
55+
v-model="$parent.notification.fluxerMessageTemplate"
56+
:required="false"
57+
placeholder=""
58+
></TemplatedTextarea>
59+
<div class="form-text">{{ $t("fluxerUseMessageTemplateDescription") }}</div>
60+
</div>
61+
</div>
62+
</template>
63+
<script>
64+
import HiddenInput from "../HiddenInput.vue";
65+
import TemplatedTextarea from "../TemplatedTextarea.vue";
66+
67+
export default {
68+
components: {
69+
TemplatedTextarea,
70+
HiddenInput,
71+
},
72+
mounted() {
73+
if (!this.$parent.notification.fluxerChannelType) {
74+
this.$parent.notification.fluxerChannelType = "channel";
75+
}
76+
if (this.$parent.notification.disableUrl === undefined) {
77+
this.$parent.notification.disableUrl = false;
78+
}
79+
// Message format: default "normal"; migrate from old checkbox
80+
if (typeof this.$parent.notification.fluxerMessageFormat === "undefined") {
81+
const hadCustom =
82+
this.$parent.notification.fluxerUseMessageTemplate === true ||
83+
!!this.$parent.notification.fluxerMessageTemplate?.trim();
84+
this.$parent.notification.fluxerMessageFormat = hadCustom ? "custom" : "normal";
85+
}
86+
},
87+
};
88+
</script>

src/components/notifications/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import CallMeBot from "./CallMeBot.vue";
1111
import SMSC from "./SMSC.vue";
1212
import DingDing from "./DingDing.vue";
1313
import Discord from "./Discord.vue";
14+
import Fluxer from "./Fluxer.vue";
1415
import Elks from "./46elks.vue";
1516
import Feishu from "./Feishu.vue";
1617
import FreeMobile from "./FreeMobile.vue";
@@ -105,6 +106,7 @@ const NotificationFormList = {
105106
smsir: SMSIR,
106107
DingDing: DingDing,
107108
discord: Discord,
109+
fluxer: Fluxer,
108110
Elks: Elks,
109111
Feishu: Feishu,
110112
FreeMobile: FreeMobile,

src/lang/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,15 @@
13581358
"discordUseMessageTemplate": "Use custom message template",
13591359
"discordUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
13601360
"discordMessageTemplate": "Message Template",
1361+
"fluxerMessageFormat": "Message Format",
1362+
"fluxerMessageFormatNormal": "Normal (rich embeds)",
1363+
"fluxerMessageFormatMinimalist": "Minimalist (short status)",
1364+
"fluxerMessageFormatCustom": "Custom template",
1365+
"fluxerUseMessageTemplate": "Use custom message template",
1366+
"fluxerUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
1367+
"fluxerMessageTemplate": "Message Template",
1368+
"Fluxer Webhook URL": "Fluxer Webhook URL",
1369+
"wayToGetFluxerURL": "You can get this by going to the target channel's settings > Webhooks > Create Webhook > Copy Webhook URL.",
13611370
"Ip Family": "IP Family",
13621371
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
13631372
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",

0 commit comments

Comments
 (0)