- Your website check should reach you.
- A small monitor with a specific job
- Give the monitor its own sender
- Find where the message should go
- Keep the token outside the program
- Remember what you last reported
- Make the phone receive a test message
- Let cron repeat the check
- Prove both messages can arrive
- Before you continue
- Who watches the machine running the check?
- Official references
Your website check should reach you.
A failed check in a terminal is easy to miss. A message on your phone is harder to overlook. Let Linux check a page on a schedule, tell you when the check fails, and send another message when it passes again.
We will build a small monitor with a private Telegram bot. It remembers the last state it successfully reported, so an unchanged failure does not produce a message every five minutes.
A small monitor with a specific job
Use a Linux machine with Python 3.8 or newer, Bash and a working user crontab. Python’s standard library handles HTTPS, JSON and local locking; no pip packages are needed. You also need a Telegram account and outbound access to your website and Telegram’s API.
Choose a small public page you own. The rule is the same as in Lesson 01: expect HTTP 200 and a stable piece of text in the returned HTML. This check does not execute JavaScript.
Give the monitor its own sender
- Open Telegram’s official @BotFather. Check the exact username.
-
Send
/newbotand follow the naming instructions. - Keep the API token BotFather gives you. Treat it as a password.
-
Open your new bot’s chat, tap Start,
and send
monitor setup.
Use a fresh bot for this exercise. A bot already connected to another application may have a webhook or another update reader, which can interfere with the next step.
Find where the message should go
Run this in your terminal after sending your bot the setup message. It asks for the token without displaying it and prints the numeric IDs of private chats found in the bot’s available updates. It does not print message contents.
python3 - <<'PY'
import getpass
import json
from urllib.request import urlopen
token = getpass.getpass('Bot token: ')
try:
with urlopen(
'https://api.telegram.org/bot' + token + '/getUpdates',
timeout=15
) as response:
data = json.load(response)
if data.get('ok') is not True:
raise ValueError()
ids = {
item['message']['chat']['id']
for item in data.get('result', [])
if item.get('message', {}).get('chat', {}).get('type') == 'private'
}
if not ids:
print('No private chat found. Send the bot a new message and retry.')
for chat_id in sorted(ids):
print('Private chat ID:', chat_id)
except Exception:
print('Lookup failed. Check the token, connection and bot setup.')
PY
For a fresh bot used only by you, expect your own chat ID. If several appear, identify your intended private chat before continuing. Do not guess the recipient.
Why must I message the bot first?
The setup message creates an update that includes your private chat ID. Starting the conversation also lets the bot send messages back to that chat.
Keep the token outside the program
The next block creates a private settings file under your home directory. Enter your real HTTPS URL, a short marker from its HTML, your token and your chat ID. Use a public URL without passwords or sensitive query parameters: the URL will appear in Telegram notifications.
python3 - <<'PY'
import getpass
import json
import os
from pathlib import Path
os.umask(0o077)
base = Path.home() / '.local/share/sa-website-monitor'
base.mkdir(parents=True, exist_ok=True)
base.chmod(0o700)
settings = base / 'settings.json'
if settings.exists():
raise SystemExit('Settings already exist. Edit that file instead.')
config = {
'url': input('Public HTTPS page URL: ').strip(),
'expected_text': input('Expected text in its HTML: '),
'bot_token': getpass.getpass('Bot token: ').strip(),
'chat_id': input('Private chat ID: ').strip()
}
with settings.open('x', encoding='utf-8') as handle:
json.dump(config, handle, indent=2)
print('Private settings saved.')
PY
The directory is restricted to your account, and newly created files use private permissions. The token is stored as plain text in that protected file; this is access control, not encryption. Root and someone controlling your account can still read it.
What if I need to correct a setting?
Open ~/.local/share/sa-website-monitor/settings.json
in your local editor. Keep valid JSON, preserve its private
permissions, and avoid sharing the file. Do not rerun the
setup to overwrite it.
Remember what you last reported
Create the script file:
nano "$HOME/.local/share/sa-website-monitor/monitor.py"
Paste the complete program below. In nano, save with Ctrl + O, press Enter, then exit with Ctrl + X. Use another editor if nano is unavailable.
#!/usr/bin/env python3
import fcntl
import json
import os
from pathlib import Path
import re
import sys
from datetime import datetime, timezone
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import (
HTTPRedirectHandler, Request, build_opener, urlopen
)
BASE = Path.home() / '.local/share/sa-website-monitor'
def log(message):
stamp = datetime.now(timezone.utc).isoformat(timespec='seconds')
print(f'{stamp} {message}', flush=True)
class HTTPSOnly(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urlsplit(newurl).scheme != 'https':
raise URLError('HTTPS redirect required')
return super().redirect_request(
req, fp, code, msg, headers, newurl
)
def check_page(config):
try:
request = Request(
config['url'], headers={'User-Agent': 'SA-Website-Monitor/1.0'}
)
with build_opener(HTTPSOnly()).open(request, timeout=15) as response:
if response.status != 200:
return 'DOWN', f'HTTP {response.status}'
body = response.read(1024 * 1024 + 1)
if len(body) > 1024 * 1024:
return 'DOWN', 'response exceeded the 1 MiB check limit'
if config['expected_text'].encode('utf-8') not in body:
return 'DOWN', 'expected text missing'
return 'UP', 'HTTP 200 and expected text received'
except HTTPError as error:
return 'DOWN', f'HTTP {error.code}'
except Exception:
return 'DOWN', 'request failed: check network, TLS or timeout'
def send_message(config, message):
payload = json.dumps({
'chat_id': config['chat_id'], 'text': message
}).encode('utf-8')
request = Request(
'https://api.telegram.org/bot' + config['bot_token'] + '/sendMessage',
data=payload, headers={'Content-Type': 'application/json'}
)
try:
with urlopen(request, timeout=15) as response:
result = json.loads(response.read(65536))
return response.status == 200 and result.get('ok') is True
except Exception:
# Never print the request URL: it contains the bot token.
return False
def main():
os.umask(0o077)
if sys.argv[1:] not in ([], ['--test-alert']):
raise ValueError('Use no arguments or --test-alert.')
settings = BASE / 'settings.json'
permissions = settings.stat()
if permissions.st_uid != os.getuid() or permissions.st_mode & 0o077:
raise ValueError('settings.json must be owned by you and private.')
config = json.loads(settings.read_text(encoding='utf-8'))
if not all(isinstance(config.get(k), str) and config[k] for k in
('url', 'expected_text', 'bot_token', 'chat_id')):
raise ValueError('Four non-empty settings are required.')
address = urlsplit(config['url'])
if address.scheme != 'https' or not address.hostname or address.username:
raise ValueError('Use a public HTTPS URL without embedded credentials.')
if not re.fullmatch(r'[0-9]+:[A-Za-z0-9_-]+', config['bot_token']):
raise ValueError('Check the bot token format.')
if not re.fullmatch(r'[0-9]+', config['chat_id']):
raise ValueError('Use the numeric ID of your private bot chat.')
with (BASE / 'monitor.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log('SKIPPED: another run is still active.')
return 0
if sys.argv[1:] == ['--test-alert']:
if not send_message(config, 'TEST: your website alert channel works.'):
log('TEST FAILED: Telegram delivery was not confirmed.')
return 1
log('TEST SENT: check your Telegram chat.')
return 0
state, reason = check_page(config)
state_file = BASE / 'last-notified-state'
previous = state_file.read_text().strip() if state_file.exists() else ''
if previous not in ('', 'UP', 'DOWN'):
raise ValueError('Invalid saved state; inspect last-notified-state.')
log(f'{state}: {reason}')
if state == previous:
return 0
label = 'INITIAL ' + state if not previous else (
'RECOVERED' if state == 'UP' else 'CHECK FAILED'
)
message = f'{label}\n{config["url"]}\n{reason}'
if not send_message(config, message):
log('DELIVERY FAILED: saved notification state was not changed.')
return 1
pending = BASE / 'state.new'
pending.write_text(state + '\n', encoding='utf-8')
pending.replace(state_file)
log('Notification accepted by Telegram; state saved.')
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except Exception:
log('MONITOR ERROR: check settings, permissions and local storage.')
sys.exit(1)
The program keeps TLS verification enabled and rejects redirects to HTTP. It checks up to 1 MiB of response data, so choose a small page. Its 15-second network timeout is not a guaranteed total runtime limit; redirects, reads and name resolution can affect elapsed time.
| Last reported state | Current check | Action |
|---|---|---|
| No saved state | UP or DOWN | Send the initial result. |
| UP | DOWN | Send CHECK FAILED. |
| DOWN | DOWN | Log the result; send no repeat. |
| DOWN | UP | Send RECOVERED. |
| Any state | Delivery not confirmed | Keep the previous reported state. |
Can a notification still arrive twice?
Yes. Telegram might accept a message while its reply fails to reach the script. The script cannot confirm delivery and may retry later. This small monitor reduces repeated unchanged alerts; it does not guarantee exactly-once delivery or that you read the message.
Make the phone receive a test message
python3 "$HOME/.local/share/sa-website-monitor/monitor.py" --test-alert
You should receive
TEST: your website alert channel works.
This mode does not check the website or change the saved website
state. If it fails, correct delivery before scheduling anything.
Then run the real check once:
python3 "$HOME/.local/share/sa-website-monitor/monitor.py"
python3 "$HOME/.local/share/sa-website-monitor/monitor.py"
The first successful delivery reports the initial state. If the page check has the same result on the second run, the terminal shows the result but Telegram receives no duplicate.
Let cron repeat the check
First find the full Python path and create the private log file:
command -v python3
(
umask 077
touch "$HOME/.local/share/sa-website-monitor/monitor.log"
chmod 600 "$HOME/.local/share/sa-website-monitor/monitor.log"
)
crontab -e
Add this line to your own crontab, preserving existing entries.
Replace /usr/bin/python3 if the path printed above
is different. Do not use sudo.
*/5 * * * * /usr/bin/python3 "$HOME/.local/share/sa-website-monitor/monitor.py" >> "$HOME/.local/share/sa-website-monitor/monitor.log" 2>&1
The program locks its local run file. If an earlier run is still active, a new one skips instead of sending competing notifications. A run that hangs still needs investigation; the lock does not repair it.
After the next scheduled time, inspect the log:
tail -n 20 "$HOME/.local/share/sa-website-monitor/monitor.log"
A fresh timestamp is evidence that the job ran. No new Telegram message can be normal when the result is unchanged. The log grows over time; include it in your retention or rotation plan.
For scheduling help, see running a script with cron. For the locking idea, read why overlapping jobs need protection.
Prove both messages can arrive
Pause this job by commenting out only its crontab line with
#. Then edit the settings file and replace the
expected text with SA_TEST_MARKER_NOT_PRESENT_7F3A.
This changes your expectation, not the website.
- Run the monitor manually. If the saved state was UP and that marker is absent, expect CHECK FAILED.
- Run it again without changing anything. Expect no repeated failure notification.
- Restore the correct marker and run it once more. If the page passes, expect RECOVERED.
- Remove the comment from the cron line to resume checks.
A five-minute schedule can miss a failure that begins and ends between runs. This version also alerts on a single failed check, so a brief network problem may trigger a message. The wording deliberately reports the check result rather than declaring a universal outage.
Nothing arrives. What should I inspect?
Check that you started the bot, entered the correct private chat ID and token, and can reach Telegram. Read the local log for configuration or delivery errors. Do not share your settings file or token while troubleshooting.
Before you continue
- Why keep the bot token outside the script?
- Why does the monitor remember the last reported state?
- What happens when Telegram delivery is not confirmed?
- What does the test-alert mode leave unchanged?
- Can this monitor report its own machine losing power?
Check your answers
- It keeps credentials out of shared program code. The separate file still needs protection.
- So repeated unchanged results do not trigger repeated notifications after a confirmed delivery.
- The old reported state remains, allowing another attempt if a later check still differs.
- It does not change the saved website state or test the page.
- No. A stopped machine cannot run this script or send the message.








