From fce824f5a8ca062e4bd4361081468542915f8260 Mon Sep 17 00:00:00 2001
From: Harry <innerpeace.zhai@gmail.com>
Date: Fri, 14 Mar 2025 09:40:13 +0800
Subject: [PATCH 1/2] feat: Support YZJ notification provider (#5686)

Co-authored-by: Frank Elsinga <frank@elsinga.de>
---
 server/notification-providers/yzj.js  | 57 +++++++++++++++++++++++++++
 server/notification.js                |  4 +-
 src/components/NotificationDialog.vue |  1 +
 src/components/notifications/YZJ.vue  | 19 +++++++++
 src/components/notifications/index.js |  2 +
 src/lang/en.json                      |  4 +-
 6 files changed, 85 insertions(+), 2 deletions(-)
 create mode 100644 server/notification-providers/yzj.js
 create mode 100644 src/components/notifications/YZJ.vue

diff --git a/server/notification-providers/yzj.js b/server/notification-providers/yzj.js
new file mode 100644
index 000000000..6bd3cba51
--- /dev/null
+++ b/server/notification-providers/yzj.js
@@ -0,0 +1,57 @@
+const NotificationProvider = require("./notification-provider");
+const { DOWN, UP } = require("../../src/util");
+const { default: axios } = require("axios");
+
+class YZJ extends NotificationProvider {
+    name = "YZJ";
+
+    /**
+     * @inheritdoc
+     */
+    async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+        let okMsg = "Sent Successfully.";
+
+        try {
+            if (heartbeatJSON !== null) {
+                msg = `${this.statusToString(heartbeatJSON["status"])} ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`;
+            }
+
+            const config = {
+                headers: {
+                    "Content-Type": "application/json",
+                },
+            };
+            const params = {
+                content: msg
+            };
+            // yzjtype=0 => general robot
+            const url = `${notification.yzjWebHookUrl}?yzjtype=0&yzjtoken=${notification.yzjToken}`;
+
+            const result = await axios.post(url, params, config);
+            if (!result.data?.success) {
+                throw new Error(result.data?.errmsg ?? "yzj's server did not respond with the expected result");
+            }
+            return okMsg;
+        } catch (error) {
+            this.throwGeneralAxiosError(error);
+        }
+    }
+
+    /**
+     * Convert status constant to string
+     * @param {string} status The status constant
+     * @returns {string} status
+     */
+    statusToString(status) {
+        switch (status) {
+            case DOWN:
+                return "❌";
+            case UP:
+                return "✅";
+            default:
+                return status;
+        }
+    }
+}
+
+module.exports = YZJ;
diff --git a/server/notification.js b/server/notification.js
index e7977eb4a..7ed62ffec 100644
--- a/server/notification.js
+++ b/server/notification.js
@@ -69,6 +69,7 @@ const Cellsynt = require("./notification-providers/cellsynt");
 const Onesender = require("./notification-providers/onesender");
 const Wpush = require("./notification-providers/wpush");
 const SendGrid = require("./notification-providers/send-grid");
+const YZJ = require("./notification-providers/yzj");
 
 class Notification {
 
@@ -154,7 +155,8 @@ class Notification {
             new GtxMessaging(),
             new Cellsynt(),
             new Wpush(),
-            new SendGrid()
+            new SendGrid(),
+            new YZJ()
         ];
         for (let item of list) {
             if (! item.name) {
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue
index f6d728029..8a2c6269d 100644
--- a/src/components/NotificationDialog.vue
+++ b/src/components/NotificationDialog.vue
@@ -183,6 +183,7 @@ export default {
                 "ServerChan": "ServerChan (Server酱)",
                 "smsc": "SMSC",
                 "WPush": "WPush(wpush.cn)",
+                "YZJ": "YZJ (云之家自定义机器人)"
             };
 
             // Sort by notification name
diff --git a/src/components/notifications/YZJ.vue b/src/components/notifications/YZJ.vue
new file mode 100644
index 000000000..63bc4c530
--- /dev/null
+++ b/src/components/notifications/YZJ.vue
@@ -0,0 +1,19 @@
+<template>
+    <div class="mb-3">
+        <label for="yzjWebHookUrl" class="form-label">{{ $t("YZJ Webhook URL") }}<span style="color: red;"><sup>*</sup></span></label>
+        <input id="yzjWebHookUrl" v-model="$parent.notification.yzjWebHookUrl" type="url" class="form-control" required />
+        <i18n-t class="form-text" keypath="wayToGetTeamsURL">
+            <a href="https://www.yunzhijia.com/opendocs/docs.html#/tutorial/index/robot" target="_blank">{{ $t("here") }}</a>
+        </i18n-t>
+    </div>
+    <div class="mb-3">
+        <label for="yzjToken" class="form-label">{{ $t("YZJ Robot Token") }}<span style="color: red;"><sup>*</sup></span></label>
+        <HiddenInput id="yzjToken" v-model="$parent.notification.yzjToken" :required="true" autocomplete="new-password"></HiddenInput>
+        <i18n-t class="form-text" keypath="wayToGetLineNotifyToken">
+            <a href="https://www.yunzhijia.com/opendocs/docs.html#/server-api/im/index?id=%e6%8e%a5%e5%8f%a3%e5%9c%b0%e5%9d%80%e5%92%8c%e6%8e%88%e6%9d%83%e7%a0%81" target="_blank">{{ $t("here") }}</a>
+        </i18n-t>
+    </div>
+</template>
+<script setup lang="ts">
+import HiddenInput from "../HiddenInput.vue";
+</script>
diff --git a/src/components/notifications/index.js b/src/components/notifications/index.js
index efa2af5c4..3bf9affd3 100644
--- a/src/components/notifications/index.js
+++ b/src/components/notifications/index.js
@@ -67,6 +67,7 @@ import Cellsynt from "./Cellsynt.vue";
 import WPush from "./WPush.vue";
 import SIGNL4 from "./SIGNL4.vue";
 import SendGrid from "./SendGrid.vue";
+import YZJ from "./YZJ.vue";
 
 /**
  * Manage all notification form.
@@ -142,6 +143,7 @@ const NotificationFormList = {
     "Cellsynt": Cellsynt,
     "WPush": WPush,
     "SendGrid": SendGrid,
+    "YZJ": YZJ,
 };
 
 export default NotificationFormList;
diff --git a/src/lang/en.json b/src/lang/en.json
index e215f1031..64958cdc6 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -1051,5 +1051,7 @@
     "RabbitMQ Password": "RabbitMQ Password",
     "rabbitmqHelpText": "To use the monitor, you will need to enable the Management Plugin in your RabbitMQ setup. For more information, please consult the {rabitmq_documentation}.",
     "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 Robot Token": "YZJ Robot token"
 }

From 9857770cc71fb5e01d05d6f6396f3fad925c94b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20Conde=20G=C3=B3mez?= <skgsergio@gmail.com>
Date: Fri, 14 Mar 2025 12:51:07 +0100
Subject: [PATCH 2/2] feat: rework notification templating and add support for
 telegram (#5637)

---
 .../notification-provider.js                  | 47 +++++++++++
 server/notification-providers/smtp.js         | 47 +----------
 server/notification-providers/telegram.js     |  8 ++
 server/notification-providers/webhook.js      | 13 +--
 src/components/TemplatedInput.vue             | 75 +++++++++++++++++
 src/components/TemplatedTextarea.vue          | 80 +++++++++++++++++++
 src/components/notifications/SMTP.vue         | 20 ++---
 src/components/notifications/Telegram.vue     | 54 +++++++++++++
 src/components/notifications/Webhook.vue      | 20 ++---
 src/lang/en.json                              | 14 +++-
 10 files changed, 290 insertions(+), 88 deletions(-)
 create mode 100644 src/components/TemplatedInput.vue
 create mode 100644 src/components/TemplatedTextarea.vue

diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js
index b9fb3d863..42e8e616d 100644
--- a/server/notification-providers/notification-provider.js
+++ b/server/notification-providers/notification-provider.js
@@ -1,3 +1,6 @@
+const { Liquid } = require("liquidjs");
+const { DOWN } = require("../../src/util");
+
 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
      * @param {any} error The error to throw
diff --git a/server/notification-providers/smtp.js b/server/notification-providers/smtp.js
index 9f3defa5e..980c7dfd3 100644
--- a/server/notification-providers/smtp.js
+++ b/server/notification-providers/smtp.js
@@ -1,7 +1,5 @@
 const nodemailer = require("nodemailer");
 const NotificationProvider = require("./notification-provider");
-const { DOWN } = require("../../src/util");
-const { Liquid } = require("liquidjs");
 
 class SMTP extends NotificationProvider {
     name = "smtp";
@@ -53,15 +51,11 @@ class SMTP extends NotificationProvider {
             const customSubject = notification.customSubject?.trim() || "";
             const customBody = notification.customBody?.trim() || "";
 
-            const context = this.generateContext(msg, monitorJSON, heartbeatJSON);
-            const engine = new Liquid();
             if (customSubject !== "") {
-                const tpl = engine.parse(customSubject);
-                subject = await engine.render(tpl, context);
+                subject = await this.renderTemplate(customSubject, msg, monitorJSON, heartbeatJSON);
             }
             if (customBody !== "") {
-                const tpl = engine.parse(customBody);
-                body = await engine.render(tpl, context);
+                body = await this.renderTemplate(customBody, msg, monitorJSON, heartbeatJSON);
             }
         }
 
@@ -78,43 +72,6 @@ class SMTP extends NotificationProvider {
 
         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;
diff --git a/server/notification-providers/telegram.js b/server/notification-providers/telegram.js
index c5bbb1909..62263db07 100644
--- a/server/notification-providers/telegram.js
+++ b/server/notification-providers/telegram.js
@@ -22,6 +22,14 @@ class Telegram extends NotificationProvider {
                 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`, {
                 params: params,
             });
diff --git a/server/notification-providers/webhook.js b/server/notification-providers/webhook.js
index 986986d44..537f94bd5 100644
--- a/server/notification-providers/webhook.js
+++ b/server/notification-providers/webhook.js
@@ -1,7 +1,6 @@
 const NotificationProvider = require("./notification-provider");
 const axios = require("axios");
 const FormData = require("form-data");
-const { Liquid } = require("liquidjs");
 
 class Webhook extends NotificationProvider {
     name = "webhook";
@@ -28,17 +27,7 @@ class Webhook extends NotificationProvider {
                 config.headers = formData.getHeaders();
                 data = formData;
             } else if (notification.webhookContentType === "custom") {
-                // Initialize LiquidJS and parse the custom Body Template
-                const engine = new Liquid();
-                const tpl = engine.parse(notification.webhookCustomBody);
-
-                // Insert templated values into Body
-                data = await engine.render(tpl,
-                    {
-                        msg,
-                        heartbeatJSON,
-                        monitorJSON
-                    });
+                data = await this.renderTemplate(notification.webhookCustomBody, msg, monitorJSON, heartbeatJSON);
             }
 
             if (notification.webhookAdditionalHeaders) {
diff --git a/src/components/TemplatedInput.vue b/src/components/TemplatedInput.vue
new file mode 100644
index 000000000..43c5382e0
--- /dev/null
+++ b/src/components/TemplatedInput.vue
@@ -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>
diff --git a/src/components/TemplatedTextarea.vue b/src/components/TemplatedTextarea.vue
new file mode 100644
index 000000000..ff0c0f9fb
--- /dev/null
+++ b/src/components/TemplatedTextarea.vue
@@ -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>
diff --git a/src/components/notifications/SMTP.vue b/src/components/notifications/SMTP.vue
index 003f90556..4e0fb4b57 100644
--- a/src/components/notifications/SMTP.vue
+++ b/src/components/notifications/SMTP.vue
@@ -67,25 +67,15 @@
             <input id="to-bcc" v-model="$parent.notification.smtpBCC" type="text" class="form-control" autocomplete="false" :required="!hasRecipient">
         </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">
             <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>
+
         <div class="mb-3">
             <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>
 
@@ -124,11 +114,15 @@
 
 <script>
 import HiddenInput from "../HiddenInput.vue";
+import TemplatedInput from "../TemplatedInput.vue";
+import TemplatedTextarea from "../TemplatedTextarea.vue";
 import ToggleSection from "../ToggleSection.vue";
 
 export default {
     components: {
         HiddenInput,
+        TemplatedInput,
+        TemplatedTextarea,
         ToggleSection,
     },
     computed: {
diff --git a/src/components/notifications/Telegram.vue b/src/components/notifications/Telegram.vue
index a072c3ed3..7f04e44c8 100644
--- a/src/components/notifications/Telegram.vue
+++ b/src/components/notifications/Telegram.vue
@@ -32,7 +32,42 @@
         <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">
         <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">
             <input v-model="$parent.notification.telegramSendSilently" class="form-check-input" type="checkbox">
             <label class="form-check-label">{{ $t("telegramSendSilently") }}</label>
@@ -57,11 +92,24 @@
 
 <script>
 import HiddenInput from "../HiddenInput.vue";
+import TemplatedTextarea from "../TemplatedTextarea.vue";
 import axios from "axios";
 
 export default {
     components: {
         HiddenInput,
+        TemplatedTextarea,
+    },
+    computed: {
+        telegramTemplatedTextareaPlaceholder() {
+            return this.$t("Example:", [
+                `
+Uptime Kuma Alert{% if monitorJSON %} - {{ monitorJSON['name'] }}{% endif %}
+
+{{ msg }}
+                `,
+            ]);
+        }
     },
     methods: {
         /**
@@ -115,3 +163,9 @@ export default {
     }
 };
 </script>
+
+<style lang="scss" scoped>
+textarea {
+    min-height: 150px;
+}
+</style>
diff --git a/src/components/notifications/Webhook.vue b/src/components/notifications/Webhook.vue
index 8c67a2745..7775a3fdd 100644
--- a/src/components/notifications/Webhook.vue
+++ b/src/components/notifications/Webhook.vue
@@ -32,20 +32,7 @@
             </template>
         </i18n-t>
         <template v-else-if="$parent.notification.webhookContentType == 'custom'">
-            <i18n-t tag="div" keypath="liquidIntroduction" class="form-text">
-                <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>
+            <TemplatedTextarea id="customBody" v-model="$parent.notification.webhookCustomBody" :required="true" :placeholder="customBodyPlaceholder"></TemplatedTextarea>
         </template>
     </div>
 
@@ -67,7 +54,12 @@
 </template>
 
 <script>
+import TemplatedTextarea from "../TemplatedTextarea.vue";
+
 export default {
+    components: {
+        TemplatedTextarea,
+    },
     data() {
         return {
             showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null,
diff --git a/src/lang/en.json b/src/lang/en.json
index 64958cdc6..118dd7fc8 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -231,6 +231,9 @@
     "templateMonitorJSON": "object describing the monitor",
     "templateLimitedToUpDownCertNotifications": "only available for UP/DOWN/Certificate expiry notifications",
     "templateLimitedToUpDownNotifications": "only available for UP/DOWN notifications",
+    "templateServiceName": "service name",
+    "templateHostnameOrURL": "hostname or URL",
+    "templateStatus": "status",
     "webhookAdditionalHeadersTitle": "Additional Headers",
     "webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook. Each header should be defined as a JSON key/value.",
     "webhookBodyPresetOption": "Preset - {0}",
@@ -421,6 +424,9 @@
     "telegramSendSilentlyDescription": "Sends the message silently. Users will receive a notification with no sound.",
     "telegramProtectContent": "Protect Forwarding/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",
     "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",
@@ -519,9 +525,6 @@
     "leave blank for default subject": "leave blank for default subject",
     "emailCustomBody": "Custom 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",
     "emailTemplateHeartbeatJSON": "object describing the heartbeat",
     "emailTemplateMsg": "message of the notification",
@@ -1053,5 +1056,8 @@
     "SendGrid API Key": "SendGrid API Key",
     "Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
     "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"
 }