Forwarding browser notifications to Telegram with a userscript
(updated ) Eiko WagenknechtI found myself in a situation where I wanted to be able to see new desktop notifications from my browser on my phone, since I was not always in front of my computer.
This led me to create a userscript for Violentmonkey (Tampermonkey and Greasemonkey should work as well) that intercepts desktop notifications emitted by the browser and redirects them to Telegram. This solution allows me to stay updated on important notifications even when I’m away from my desk.
How it works
The userscript monitors the website for changes in two ways (which can be enabled or disabled individually):
- It intercepts desktop notifications and sends them to Telegram, including the notification title, body, and any additional data.
- It monitors tab title changes. This gives less details, but still sends a message to Telegram when a change is detected.
The userscript uses the Telegram Bot API to send messages. This requires creating a bot and obtaining a Bot Token and Chat ID. Telegram provides instructions for this process here.
For my setup, I created a personal group chat and used its Chat ID. This allows me to receive messages in a dedicated space while also allowing me use the same bot for multiple scripts.
Permissions and configuration
The script requires several permissions, as it needs to intercept desktop notifications and send messages to the Telegram API. These permissions are necessary for the script to work:
- The script uses
GM_xmlhttpRequest
to send the messages. This is a Greasemonkey function that is also available in Violentmonkey and Tampermonkey. It is used to send the messages to the Telegram API. - The script uses
unsafeWindow
to intercept desktop notifications. This is necessary because the script needs to replace theNotification
object with a custom one that sends the message to Telegram.
By default, the script runs on all websites, but remains inactive until you configure it.
If you want to limit the script to only run on specific websites, you can change the @match
metadata in the script header.
For example, to limit the script to only run on Microsoft Teams, you could change the @match
line to // @match https://*.teams.microsoft.com/*
.
On first run, you’ll be prompted to configure the Telegram Bot Token and Chat ID. This is how you do it:
- Access the Violentmonkey menu by clicking its icon in your browser.
- Look for the “Set Telegram Settings” option and click it. You will be prompted to enter the Bot Token and Chat ID.
If you want to change the settings later, you can do so via the same menu.
Lastly, you can enable or disable the title change tracking and notification interception for the current domain via the Violentmonkey menu. This is useful if you want to temporarily stop notifications from a specific site or if you only want to receive notifications from certain domains.
The script
Here is the full script that you can copy and paste into a new Violentmonkey script:
// ==UserScript==
// @name Notification Monitor
// @version 1.0
// @description Monitors web pages for new notifications and tab title changes. Sends detailed alerts via Telegram. See the user menu for settings.
// @author Eiko Wagenknecht
// @namespace https://eikowagenknecht.de/
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant unsafeWindow
// ==/UserScript==
(function () {
"use strict";
// Custom Logging
function debugLog(message) {
console.log(
`%c[Notification Monitor] ${message}`,
"background: #f0f0f0; color: #333; padding: 2px 5px; border-radius: 3px;",
);
}
// Get the current domain
const currentDomain = window.location.hostname;
// Default pattern that matches everything
const DEFAULT_PATTERN = ".*";
// Track last detailed message timestamp and pending generic messages
let lastDetailedMessageTimestamp = 0;
let pendingGenericMessageTimeout = null;
const MESSAGE_COOLDOWN = 2000; // 2 seconds in milliseconds
const FUTURE_CHECK_DELAY = 500; // 0.5 seconds to wait for a possible detailed message
// Configuration
const config = {
get telegramBotToken() {
return GM_getValue("telegramBotToken", "");
},
set telegramBotToken(value) {
GM_setValue("telegramBotToken", value);
},
get telegramChatId() {
return GM_getValue("telegramChatId", "");
},
set telegramChatId(value) {
GM_setValue("telegramChatId", value);
},
get titleChangeTrackingEnabled() {
return GM_getValue(`titleChangeTrackingEnabled_${currentDomain}`, false);
},
set titleChangeTrackingEnabled(value) {
GM_setValue(`titleChangeTrackingEnabled_${currentDomain}`, value);
updateTitleChangeTracking();
},
get titlePattern() {
return GM_getValue(`titlePattern_${currentDomain}`, "");
},
set titlePattern(value) {
GM_setValue(`titlePattern_${currentDomain}`, value);
},
get notificationInterceptionEnabled() {
return GM_getValue(
`notificationInterceptionEnabled_${currentDomain}`,
false,
);
},
set notificationInterceptionEnabled(value) {
GM_setValue(`notificationInterceptionEnabled_${currentDomain}`, value);
updateNotificationInterception();
},
};
// Menu command IDs
const menuCommandIds = {
configure: null,
toggleTitleTracking: null,
configureTitlePattern: null,
toggleNotificationInterception: null,
};
// Register menu commands
function updateMenuCommands() {
// Unregister existing commands
for (const id of Object.values(menuCommandIds)) {
if (id !== null) GM_unregisterMenuCommand(id);
}
// Register new commands
menuCommandIds.configure = GM_registerMenuCommand(
"Configure Telegram Settings",
configureTelegramSettings,
);
menuCommandIds.toggleTitleTracking = GM_registerMenuCommand(
`${
config.titleChangeTrackingEnabled ? "Disable" : "Enable"
} Title Change Tracking for ${currentDomain}`,
toggleTitleChangeTracking,
);
menuCommandIds.configureTitlePattern = GM_registerMenuCommand(
"Configure Title Pattern",
configureTitlePattern,
);
menuCommandIds.toggleNotificationInterception = GM_registerMenuCommand(
`${
config.notificationInterceptionEnabled ? "Disable" : "Enable"
} Notification Interception for ${currentDomain}`,
toggleNotificationInterception,
);
debugLog("Menu commands updated");
}
function configureTitlePattern() {
debugLog("Configuring title pattern");
const currentPattern =
config.titlePattern === DEFAULT_PATTERN ? "" : config.titlePattern;
const pattern = prompt(
"Enter the title pattern to match (regex).\n" +
"Leave empty or enter invalid pattern to track all title changes.\n" +
"Example for Teams: ^\\((\\d+)\\)",
currentPattern,
);
if (pattern !== null) {
// Only proceed if user didn't click Cancel
let finalPattern = pattern.trim();
let message = "";
if (!finalPattern) {
finalPattern = DEFAULT_PATTERN;
message = "Empty pattern provided. Tracking ALL title changes.";
debugLog(message);
} else {
try {
// Test if the pattern is valid regex
new RegExp(finalPattern);
message = `Title pattern updated to: ${finalPattern}`;
debugLog(message);
} catch (e) {
finalPattern = DEFAULT_PATTERN;
message = `Invalid regular expression! Falling back to tracking ALL title changes.\nError: ${e.message}`;
debugLog(`Invalid pattern provided: ${e.message}`);
}
}
config.titlePattern = finalPattern;
alert(message);
}
}
function configureTelegramSettings() {
debugLog("Configuring Telegram settings");
const token = prompt(
"Enter your Telegram Bot Token (leave empty to clear):",
config.telegramBotToken,
);
if (token !== null) {
config.telegramBotToken = token.trim();
const chatId = prompt(
"Enter your Telegram Chat ID (leave empty to clear):",
config.telegramChatId,
);
if (chatId !== null) {
config.telegramChatId = chatId.trim();
alert(
`Telegram settings ${
config.telegramBotToken && config.telegramChatId
? "updated."
: "cleared."
}`,
);
updateMenuCommands();
checkConfiguration(false);
if (config.telegramBotToken && config.telegramChatId) {
sendTestMessage("Telegram settings updated. This is a test message.");
}
}
}
}
function toggleTitleChangeTracking() {
config.titleChangeTrackingEnabled = !config.titleChangeTrackingEnabled;
updateMenuCommands();
}
function toggleNotificationInterception() {
config.notificationInterceptionEnabled =
!config.notificationInterceptionEnabled;
updateMenuCommands();
}
function updateTitleChangeTracking() {
if (config.titleChangeTrackingEnabled) {
observeTitleChanges();
debugLog(`Title change tracking enabled for ${currentDomain}`);
} else {
stopObservingTitleChanges();
debugLog(`Title change tracking disabled for ${currentDomain}`);
}
}
function updateNotificationInterception() {
if (
config.notificationInterceptionEnabled &&
unsafeWindow.Notification.permission === "granted"
) {
interceptNotifications();
debugLog(`Notification interception enabled for ${currentDomain}`);
} else {
restoreOriginalNotifications();
debugLog(`Notification interception disabled for ${currentDomain}`);
}
}
function checkConfiguration(isInitialCheck = false) {
const isConfigured = config.telegramBotToken && config.telegramChatId;
if (!isConfigured) {
const missingItems = [];
if (!config.telegramBotToken) missingItems.push("Telegram Bot Token");
if (!config.telegramChatId) missingItems.push("Telegram Chat ID");
const message = `Notification Monitor: Configuration incomplete. Please set the following:\n${missingItems.join(
"\n",
)}\n\nUse the userscript manager's menu to configure.`;
if (isInitialCheck) {
alert(message);
}
debugLog(`Configuration incomplete: ${missingItems.join(", ")}`);
return false;
}
debugLog("Configuration is complete");
return true;
}
function sendTestMessage(message) {
if (!checkConfiguration()) {
return;
}
sendTelegramMessage(message)
.then(() => {
debugLog("Test message sent successfully");
alert(
"Test message sent successfully. Check your Telegram for the message.",
);
})
.catch((error) => {
debugLog(`Failed to send test message: ${error}`);
alert(
"Failed to send test message. Please check your Telegram configuration.",
);
});
}
function sendTelegramMessage(message) {
if (!checkConfiguration()) {
return Promise.reject("Incomplete configuration");
}
const TELEGRAM_API = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: TELEGRAM_API,
data: JSON.stringify({
chat_id: config.telegramChatId,
text: message,
parse_mode: "HTML",
}),
headers: {
"Content-Type": "application/json",
},
onload: (response) => {
if (response.status === 200) {
resolve(response);
} else {
reject(`HTTP ${response.status}: ${response.statusText}`);
}
},
onerror: (error) => {
console.error("Error sending Telegram message:", error);
reject(error);
},
});
});
}
function sendDetailedMessage(title, body) {
// Cancel any pending generic message that might be waiting to be sent
if (pendingGenericMessageTimeout) {
clearTimeout(pendingGenericMessageTimeout);
pendingGenericMessageTimeout = null;
debugLog(
"Cancelled pending generic message because a detailed message arrived",
);
}
// Always update the timestamp for detailed messages - these are prioritized
lastDetailedMessageTimestamp = Date.now();
const message = `<b>${currentDomain} > ${title}</b>\n${body}`;
return sendTelegramMessage(message)
.then(() => {
debugLog("Detailed message sent successfully");
})
.catch((error) => {
debugLog(`Failed to send detailed message: ${error}`);
});
}
function sendGenericMessage(title, body) {
// Check if a detailed message was sent in the past 2 seconds
const currentTime = Date.now();
const timeSinceLastDetailedMessage =
currentTime - lastDetailedMessageTimestamp;
if (timeSinceLastDetailedMessage < MESSAGE_COOLDOWN) {
debugLog(
`Generic message suppressed - detailed message sent ${timeSinceLastDetailedMessage}ms ago`,
);
return Promise.resolve(); // Skip sending generic message
}
// Wait briefly to see if a detailed message is coming right after this generic one
if (pendingGenericMessageTimeout) {
clearTimeout(pendingGenericMessageTimeout);
pendingGenericMessageTimeout = null;
}
return new Promise((resolve) => {
const messageData = {
title: title,
body: body,
};
pendingGenericMessageTimeout = setTimeout(() => {
pendingGenericMessageTimeout = null;
// Check again if a detailed message came in while we were waiting
const newTimeSinceDetailedMessage =
Date.now() - lastDetailedMessageTimestamp;
if (newTimeSinceDetailedMessage < MESSAGE_COOLDOWN) {
debugLog(
`Generic message suppressed after delay - detailed message arrived during wait`,
);
resolve();
return;
}
// If still no detailed message, proceed with sending the generic one
const message = `<b>${currentDomain} > ${messageData.title}</b>\n${messageData.body}`;
sendTelegramMessage(message)
.then(() => {
debugLog("Generic message sent successfully after delay check");
resolve();
})
.catch((error) => {
debugLog(`Failed to send generic message: ${error}`);
resolve();
});
}, FUTURE_CHECK_DELAY);
});
}
function sendInitializationMessage() {
const message = `Notification monitor initialized for ${currentDomain}`;
sendTelegramMessage(message)
.then(() => {
debugLog("Initialization message sent successfully");
})
.catch((error) => {
debugLog(`Failed to send initialization message: ${error}`);
alert(
"Notification Monitor: Failed to send initialization message to Telegram. Please check your configuration and network connection.",
);
});
}
// Helper function to create a human-readable string representation of an object
function objectToString(obj, indent = "") {
if (typeof obj !== "object" || obj === null) {
return String(obj);
}
const lines = Object.entries(obj).map(([key, value]) => {
if (typeof value === "object" && value !== null) {
return `${indent}${key}:\n${objectToString(value, `${indent} `)}`;
}
return `${indent}${key}: ${value}`;
});
return lines.join("\n");
}
// Notification interception
let OriginalNotification;
function interceptNotifications() {
if (OriginalNotification) return; // Already intercepted
OriginalNotification = unsafeWindow.Notification;
function CustomNotification(title, options) {
debugLog(
`Intercepted desktop notification: ${title}, ${JSON.stringify(options)}`,
);
// Send as detailed message with new format
sendDetailedMessage(title, options.body || "New notification");
// Add data attribute information if it exists (for debugging)
if (options.data) {
debugLog(`Additional Data:\n${objectToString(options.data)}`);
}
return new OriginalNotification(title, options);
}
CustomNotification.permission = OriginalNotification.permission;
CustomNotification.requestPermission =
OriginalNotification.requestPermission.bind(OriginalNotification);
unsafeWindow.Notification = CustomNotification;
}
function restoreOriginalNotifications() {
if (OriginalNotification) {
unsafeWindow.Notification = OriginalNotification;
OriginalNotification = null;
debugLog("Restored original notifications");
}
}
// Tab title change monitoring
let lastTabTitle = document.title;
let titleObserver;
function observeTitleChanges() {
if (titleObserver) return; // Already observing
const target = document.querySelector("title");
titleObserver = new MutationObserver(() => {
const currentTitle = document.title;
if (currentTitle !== lastTabTitle) {
debugLog(
`(Tab title) Title changed from "${lastTabTitle}" to "${currentTitle}"`,
);
try {
const pattern = new RegExp(config.titlePattern);
if (pattern.test(currentTitle)) {
// Send as generic message with new format, but only if no detailed message was sent recently
sendGenericMessage("Tab title changed", currentTitle);
} else {
debugLog(
`Title change ignored - doesn't match pattern: ${config.titlePattern}`,
);
}
} catch (e) {
debugLog(
`Error with pattern matching: ${e.message}. Falling back to tracking all changes.`,
);
config.titlePattern = DEFAULT_PATTERN;
// Send as generic message with new format, but only if no detailed message was sent recently
sendGenericMessage("Tab title changed", currentTitle);
}
lastTabTitle = currentTitle;
}
});
titleObserver.observe(target, {
subtree: true,
characterData: true,
childList: true,
});
}
function stopObservingTitleChanges() {
if (titleObserver) {
titleObserver.disconnect();
titleObserver = null;
debugLog("Stopped observing title changes");
}
}
// Initialize the script
function init() {
debugLog(`Notification monitor setup starting for ${currentDomain}.`);
updateMenuCommands();
if (checkConfiguration(true)) {
updateTitleChangeTracking();
updateNotificationInterception();
sendInitializationMessage();
} else {
debugLog("Telegram configuration incomplete. Skipping initialization.");
}
debugLog(`Notification monitor setup complete for ${currentDomain}.`);
}
// Run the initialization
init();
})();
No Comments? No Problem.
This blog doesn't support comments, but your thoughts and questions are always welcome. Reach out through the contact details in the footer below.
Support Me
If you found this page helpful and want to say thanks, I would be very grateful if you could use this link for your next purchase on amazon.com. I get a small commission, and it costs you nothing extra. If you'd like to support me in another way, you can find more options here.