feat: rework notification templating and add support for telegram (#5637)
Some checks are pending
Auto Test / auto-test (18, ARM64) (push) Blocked by required conditions
Auto Test / auto-test (18, macos-latest) (push) Blocked by required conditions
Auto Test / auto-test (18, ubuntu-latest) (push) Blocked by required conditions
Auto Test / auto-test (18, windows-latest) (push) Blocked by required conditions
Auto Test / auto-test (20, ARM64) (push) Blocked by required conditions
Auto Test / auto-test (20, macos-latest) (push) Blocked by required conditions
Auto Test / auto-test (20, ubuntu-latest) (push) Blocked by required conditions
Auto Test / auto-test (20, windows-latest) (push) Blocked by required conditions
Auto Test / armv7-simple-test (18, ARMv7) (push) Waiting to run
Auto Test / armv7-simple-test (20, ARMv7) (push) Waiting to run
Auto Test / check-linters (push) Waiting to run
Auto Test / e2e-test (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Merge Conflict Labeler / Labeling (push) Waiting to run
validate / json-yaml-validate (push) Waiting to run
validate / validate (push) Waiting to run

This commit is contained in:
Sergio Conde Gómez 2025-03-14 12:51:07 +01:00 committed by GitHub
parent fce824f5a8
commit 9857770cc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 290 additions and 88 deletions

View file

@ -1,3 +1,6 @@
const { Liquid } = require("liquidjs");
const { DOWN } = require("../../src/util");
class NotificationProvider { class NotificationProvider {
/** /**
@ -49,6 +52,50 @@ class NotificationProvider {
} }
} }
/**
* Renders a message template with notification context
* @param {string} template the template
* @param {string} msg the message that will be included in the context
* @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only)
* @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
* @returns {Promise<string>} rendered template
*/
async renderTemplate(template, msg, monitorJSON, heartbeatJSON) {
const engine = new Liquid();
const parsedTpl = engine.parse(template);
// Let's start with dummy values to simplify code
let monitorName = "Monitor Name not available";
let monitorHostnameOrURL = "testing.hostname";
if (monitorJSON !== null) {
monitorName = monitorJSON["name"];
monitorHostnameOrURL = this.extractAddress(monitorJSON);
}
let serviceStatus = "⚠️ Test";
if (heartbeatJSON !== null) {
serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up";
}
const context = {
// for v1 compatibility, to be removed in v3
"STATUS": serviceStatus,
"NAME": monitorName,
"HOSTNAME_OR_URL": monitorHostnameOrURL,
// variables which are officially supported
"status": serviceStatus,
"name": monitorName,
"hostnameOrURL": monitorHostnameOrURL,
monitorJSON,
heartbeatJSON,
msg,
};
return engine.render(parsedTpl, context);
}
/** /**
* Throws an error * Throws an error
* @param {any} error The error to throw * @param {any} error The error to throw

View file

@ -1,7 +1,5 @@
const nodemailer = require("nodemailer"); const nodemailer = require("nodemailer");
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const { DOWN } = require("../../src/util");
const { Liquid } = require("liquidjs");
class SMTP extends NotificationProvider { class SMTP extends NotificationProvider {
name = "smtp"; name = "smtp";
@ -53,15 +51,11 @@ class SMTP extends NotificationProvider {
const customSubject = notification.customSubject?.trim() || ""; const customSubject = notification.customSubject?.trim() || "";
const customBody = notification.customBody?.trim() || ""; const customBody = notification.customBody?.trim() || "";
const context = this.generateContext(msg, monitorJSON, heartbeatJSON);
const engine = new Liquid();
if (customSubject !== "") { if (customSubject !== "") {
const tpl = engine.parse(customSubject); subject = await this.renderTemplate(customSubject, msg, monitorJSON, heartbeatJSON);
subject = await engine.render(tpl, context);
} }
if (customBody !== "") { if (customBody !== "") {
const tpl = engine.parse(customBody); body = await this.renderTemplate(customBody, msg, monitorJSON, heartbeatJSON);
body = await engine.render(tpl, context);
} }
} }
@ -78,43 +72,6 @@ class SMTP extends NotificationProvider {
return okMsg; return okMsg;
} }
/**
* Generate context for LiquidJS
* @param {string} msg the message that will be included in the context
* @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only)
* @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
* @returns {{STATUS: string, status: string, HOSTNAME_OR_URL: string, hostnameOrUrl: string, NAME: string, name: string, monitorJSON: ?object, heartbeatJSON: ?object, msg: string}} the context
*/
generateContext(msg, monitorJSON, heartbeatJSON) {
// Let's start with dummy values to simplify code
let monitorName = "Monitor Name not available";
let monitorHostnameOrURL = "testing.hostname";
if (monitorJSON !== null) {
monitorName = monitorJSON["name"];
monitorHostnameOrURL = this.extractAddress(monitorJSON);
}
let serviceStatus = "⚠️ Test";
if (heartbeatJSON !== null) {
serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up";
}
return {
// for v1 compatibility, to be removed in v3
"STATUS": serviceStatus,
"NAME": monitorName,
"HOSTNAME_OR_URL": monitorHostnameOrURL,
// variables which are officially supported
"status": serviceStatus,
"name": monitorName,
"hostnameOrURL": monitorHostnameOrURL,
monitorJSON,
heartbeatJSON,
msg,
};
}
} }
module.exports = SMTP; module.exports = SMTP;

View file

@ -22,6 +22,14 @@ class Telegram extends NotificationProvider {
params.message_thread_id = notification.telegramMessageThreadID; params.message_thread_id = notification.telegramMessageThreadID;
} }
if (notification.telegramUseTemplate) {
params.text = await this.renderTemplate(notification.telegramTemplate, msg, monitorJSON, heartbeatJSON);
if (notification.telegramTemplateParseMode !== "plain") {
params.parse_mode = notification.telegramTemplateParseMode;
}
}
await axios.get(`${url}/bot${notification.telegramBotToken}/sendMessage`, { await axios.get(`${url}/bot${notification.telegramBotToken}/sendMessage`, {
params: params, params: params,
}); });

View file

@ -1,7 +1,6 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const axios = require("axios"); const axios = require("axios");
const FormData = require("form-data"); const FormData = require("form-data");
const { Liquid } = require("liquidjs");
class Webhook extends NotificationProvider { class Webhook extends NotificationProvider {
name = "webhook"; name = "webhook";
@ -28,17 +27,7 @@ class Webhook extends NotificationProvider {
config.headers = formData.getHeaders(); config.headers = formData.getHeaders();
data = formData; data = formData;
} else if (notification.webhookContentType === "custom") { } else if (notification.webhookContentType === "custom") {
// Initialize LiquidJS and parse the custom Body Template data = await this.renderTemplate(notification.webhookCustomBody, msg, monitorJSON, heartbeatJSON);
const engine = new Liquid();
const tpl = engine.parse(notification.webhookCustomBody);
// Insert templated values into Body
data = await engine.render(tpl,
{
msg,
heartbeatJSON,
monitorJSON
});
} }
if (notification.webhookAdditionalHeaders) { if (notification.webhookAdditionalHeaders) {

View file

@ -0,0 +1,75 @@
<template>
<div class="form-text mb-2">
<i18n-t tag="div" keypath="liquidIntroduction">
<a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
</i18n-t>
<code v-pre>{{ msg }}</code>: {{ $t("templateMsg") }}<br />
<code v-pre>{{ name }}</code>: {{ $t("templateServiceName") }}<br />
<code v-pre>{{ status }}</code>: {{ $t("templateStatus") }}<br />
<code v-pre>{{ hostnameOrURL }}</code>: {{ $t("templateHostnameOrURL") }}<br />
<code v-pre>{{ heartbeatJSON }}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
<code v-pre>{{ monitorJSON }}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
</div>
<input
:id="id"
ref="templatedInput"
v-model="model"
type="text"
class="form-control"
:placeholder="placeholder"
:required="required"
autocomplete="false"
>
</template>
<script>
export default {
props: {
/**
* The value of the templated input.
*/
modelValue: {
type: String,
default: ""
},
/**
* id for the templated input.
*/
id: {
type: String,
required: true,
},
/**
* Whether the templated input is required.
* @example true
*/
required: {
type: Boolean,
required: true,
},
/**
* Placeholder text for the templated input.
*/
placeholder: {
type: String,
default: ""
},
},
emits: [ "update:modelValue" ],
computed: {
/**
* Send value update to parent on change.
*/
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
}
}
},
};
</script>

View file

@ -0,0 +1,80 @@
<template>
<div class="form-text mb-2">
<i18n-t tag="div" keypath="liquidIntroduction">
<a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
</i18n-t>
<code v-pre>{{ msg }}</code>: {{ $t("templateMsg") }}<br />
<code v-pre>{{ name }}</code>: {{ $t("templateServiceName") }}<br />
<code v-pre>{{ status }}</code>: {{ $t("templateStatus") }}<br />
<code v-pre>{{ hostnameOrURL }}</code>: {{ $t("templateHostnameOrURL") }}<br />
<code v-pre>{{ heartbeatJSON }}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
<code v-pre>{{ monitorJSON }}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
</div>
<textarea
:id="id"
ref="templatedTextarea"
v-model="model"
class="form-control"
:placeholder="placeholder"
:required="required"
autocomplete="false"
></textarea>
</template>
<script>
export default {
props: {
/**
* The value of the templated textarea.
*/
modelValue: {
type: String,
default: ""
},
/**
* id for the templated textarea.
*/
id: {
type: String,
required: true,
},
/**
* Whether the templated textarea is required.
* @example true
*/
required: {
type: Boolean,
required: true,
},
/**
* Placeholder text for the templated textarea.
*/
placeholder: {
type: String,
default: ""
},
},
emits: [ "update:modelValue" ],
computed: {
/**
* Send value update to parent on change.
*/
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
}
}
},
};
</script>
<style lang="scss" scoped>
textarea {
min-height: 150px;
}
</style>

View file

@ -67,25 +67,15 @@
<input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient"> <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
</div> </div>
<p class="form-text">
<i18n-t tag="div" keypath="smtpLiquidIntroduction" class="form-text mb-3">
<a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
</i18n-t>
<code v-pre>{{name}}</code>: {{ $t("emailTemplateServiceName") }}<br />
<code v-pre>{{msg}}</code>: {{ $t("emailTemplateMsg") }}<br />
<code v-pre>{{status}}</code>: {{ $t("emailTemplateStatus") }}<br />
<code v-pre>{{heartbeatJSON}}</code>: {{ $t("emailTemplateHeartbeatJSON") }}<b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
<code v-pre>{{monitorJSON}}</code>: {{ $t("emailTemplateMonitorJSON") }} <b>{{ $t("emailTemplateLimitedToUpDownNotification") }}</b><br />
<code v-pre>{{hostnameOrURL}}</code>: {{ $t("emailTemplateHostnameOrURL") }}<br />
</p>
<div class="mb-3"> <div class="mb-3">
<label for="subject-email" class="form-label">{{ $t("emailCustomSubject") }}</label> <label for="subject-email" class="form-label">{{ $t("emailCustomSubject") }}</label>
<input id="subject-email" v-model="$parent.notification.customSubject" type="text" class="form-control" autocomplete="false" placeholder=""> <TemplatedInput id="subject-email" v-model="$parent.notification.customSubject" :required="false" placeholder=""></TemplatedInput>
<div class="form-text">{{ $t("leave blank for default subject") }}</div> <div class="form-text">{{ $t("leave blank for default subject") }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="body-email" class="form-label">{{ $t("emailCustomBody") }}</label> <label for="body-email" class="form-label">{{ $t("emailCustomBody") }}</label>
<textarea id="body-email" v-model="$parent.notification.customBody" type="text" class="form-control" autocomplete="false" placeholder=""></textarea> <TemplatedTextarea id="body-email" v-model="$parent.notification.customBody" :required="false" placeholder=""></TemplatedTextarea>
<div class="form-text">{{ $t("leave blank for default body") }}</div> <div class="form-text">{{ $t("leave blank for default body") }}</div>
</div> </div>
@ -124,11 +114,15 @@
<script> <script>
import HiddenInput from "../HiddenInput.vue"; import HiddenInput from "../HiddenInput.vue";
import TemplatedInput from "../TemplatedInput.vue";
import TemplatedTextarea from "../TemplatedTextarea.vue";
import ToggleSection from "../ToggleSection.vue"; import ToggleSection from "../ToggleSection.vue";
export default { export default {
components: { components: {
HiddenInput, HiddenInput,
TemplatedInput,
TemplatedTextarea,
ToggleSection, ToggleSection,
}, },
computed: { computed: {

View file

@ -32,7 +32,42 @@
<label for="message_thread_id" class="form-label">{{ $t("telegramMessageThreadID") }}</label> <label for="message_thread_id" class="form-label">{{ $t("telegramMessageThreadID") }}</label>
<input id="message_thread_id" v-model="$parent.notification.telegramMessageThreadID" type="text" class="form-control"> <input id="message_thread_id" v-model="$parent.notification.telegramMessageThreadID" type="text" class="form-control">
<p class="form-text">{{ $t("telegramMessageThreadIDDescription") }}</p> <p class="form-text">{{ $t("telegramMessageThreadIDDescription") }}</p>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input v-model="$parent.notification.telegramUseTemplate" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t("telegramUseTemplate") }}</label>
</div>
<div class="form-text">
{{ $t("telegramUseTemplateDescription") }}
</div>
</div>
<template v-if="$parent.notification.telegramUseTemplate">
<div class="mb-3">
<label class="form-label" for="message_parse_mode">{{ $t("Message Format") }}</label>
<select
id="message_parse_mode"
v-model="$parent.notification.telegramTemplateParseMode"
class="form-select"
required
>
<option value="plain">{{ $t("Plain Text") }}</option>
<option value="HTML">HTML</option>
<option value="MarkdownV2">MarkdownV2</option>
</select>
<i18n-t tag="p" keypath="telegramTemplateFormatDescription" class="form-text">
<a href="https://core.telegram.org/bots/api#formatting-options" target="_blank">{{ $t("documentation") }}</a>
</i18n-t>
<label class="form-label" for="message_template">{{ $t('Message Template') }}</label>
<TemplatedTextarea id="message_template" v-model="$parent.notification.telegramTemplate" :required="true" :placeholder="telegramTemplatedTextareaPlaceholder"></TemplatedTextarea>
</div>
</template>
<div class="mb-3">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input v-model="$parent.notification.telegramSendSilently" class="form-check-input" type="checkbox"> <input v-model="$parent.notification.telegramSendSilently" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t("telegramSendSilently") }}</label> <label class="form-check-label">{{ $t("telegramSendSilently") }}</label>
@ -57,11 +92,24 @@
<script> <script>
import HiddenInput from "../HiddenInput.vue"; import HiddenInput from "../HiddenInput.vue";
import TemplatedTextarea from "../TemplatedTextarea.vue";
import axios from "axios"; import axios from "axios";
export default { export default {
components: { components: {
HiddenInput, HiddenInput,
TemplatedTextarea,
},
computed: {
telegramTemplatedTextareaPlaceholder() {
return this.$t("Example:", [
`
Uptime Kuma Alert{% if monitorJSON %} - {{ monitorJSON['name'] }}{% endif %}
{{ msg }}
`,
]);
}
}, },
methods: { methods: {
/** /**
@ -115,3 +163,9 @@ export default {
} }
}; };
</script> </script>
<style lang="scss" scoped>
textarea {
min-height: 150px;
}
</style>

View file

@ -32,20 +32,7 @@
</template> </template>
</i18n-t> </i18n-t>
<template v-else-if="$parent.notification.webhookContentType == 'custom'"> <template v-else-if="$parent.notification.webhookContentType == 'custom'">
<i18n-t tag="div" keypath="liquidIntroduction" class="form-text"> <TemplatedTextarea id="customBody" v-model="$parent.notification.webhookCustomBody" :required="true" :placeholder="customBodyPlaceholder"></TemplatedTextarea>
<a href="https://liquidjs.com/" target="_blank">{{ $t("documentation") }}</a>
</i18n-t>
<code v-pre>{{msg}}</code>: {{ $t("templateMsg") }}<br />
<code v-pre>{{heartbeatJSON}}</code>: {{ $t("templateHeartbeatJSON") }} <b>({{ $t("templateLimitedToUpDownNotifications") }})</b><br />
<code v-pre>{{monitorJSON}}</code>: {{ $t("templateMonitorJSON") }} <b>({{ $t("templateLimitedToUpDownCertNotifications") }})</b><br />
<textarea
id="customBody"
v-model="$parent.notification.webhookCustomBody"
class="form-control"
:placeholder="customBodyPlaceholder"
required
></textarea>
</template> </template>
</div> </div>
@ -67,7 +54,12 @@
</template> </template>
<script> <script>
import TemplatedTextarea from "../TemplatedTextarea.vue";
export default { export default {
components: {
TemplatedTextarea,
},
data() { data() {
return { return {
showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null, showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null,

View file

@ -231,6 +231,9 @@
"templateMonitorJSON": "object describing the monitor", "templateMonitorJSON": "object describing the monitor",
"templateLimitedToUpDownCertNotifications": "only available for UP/DOWN/Certificate expiry notifications", "templateLimitedToUpDownCertNotifications": "only available for UP/DOWN/Certificate expiry notifications",
"templateLimitedToUpDownNotifications": "only available for UP/DOWN notifications", "templateLimitedToUpDownNotifications": "only available for UP/DOWN notifications",
"templateServiceName": "service name",
"templateHostnameOrURL": "hostname or URL",
"templateStatus": "status",
"webhookAdditionalHeadersTitle": "Additional Headers", "webhookAdditionalHeadersTitle": "Additional Headers",
"webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook. Each header should be defined as a JSON key/value.", "webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook. Each header should be defined as a JSON key/value.",
"webhookBodyPresetOption": "Preset - {0}", "webhookBodyPresetOption": "Preset - {0}",
@ -421,6 +424,9 @@
"telegramSendSilentlyDescription": "Sends the message silently. Users will receive a notification with no sound.", "telegramSendSilentlyDescription": "Sends the message silently. Users will receive a notification with no sound.",
"telegramProtectContent": "Protect Forwarding/Saving", "telegramProtectContent": "Protect Forwarding/Saving",
"telegramProtectContentDescription": "If enabled, the bot messages in Telegram will be protected from forwarding and saving.", "telegramProtectContentDescription": "If enabled, the bot messages in Telegram will be protected from forwarding and saving.",
"telegramUseTemplate": "Use custom message template",
"telegramUseTemplateDescription": "If enabled, the message will be sent using a custom template.",
"telegramTemplateFormatDescription": "Telegram allows using different markup languages for messages, see Telegram {0} for specifc details.",
"supportTelegramChatID": "Support Direct Chat / Group / Channel's Chat ID", "supportTelegramChatID": "Support Direct Chat / Group / Channel's Chat ID",
"wayToGetTelegramChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:", "wayToGetTelegramChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:",
"YOUR BOT TOKEN HERE": "YOUR BOT TOKEN HERE", "YOUR BOT TOKEN HERE": "YOUR BOT TOKEN HERE",
@ -519,9 +525,6 @@
"leave blank for default subject": "leave blank for default subject", "leave blank for default subject": "leave blank for default subject",
"emailCustomBody": "Custom Body", "emailCustomBody": "Custom Body",
"leave blank for default body": "leave blank for default body", "leave blank for default body": "leave blank for default body",
"emailTemplateServiceName": "Service Name",
"emailTemplateHostnameOrURL": "Hostname or URL",
"emailTemplateStatus": "Status",
"emailTemplateMonitorJSON": "object describing the monitor", "emailTemplateMonitorJSON": "object describing the monitor",
"emailTemplateHeartbeatJSON": "object describing the heartbeat", "emailTemplateHeartbeatJSON": "object describing the heartbeat",
"emailTemplateMsg": "message of the notification", "emailTemplateMsg": "message of the notification",
@ -1053,5 +1056,8 @@
"SendGrid API Key": "SendGrid API Key", "SendGrid API Key": "SendGrid API Key",
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas", "Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
"YZJ Webhook URL": "YZJ Webhook URL", "YZJ Webhook URL": "YZJ Webhook URL",
"YZJ Robot Token": "YZJ Robot token" "YZJ Robot Token": "YZJ Robot token",
"Plain Text": "Plain Text",
"Message Template": "Message Template",
"Template Format": "Template Format"
} }