notifier: refactor watcher.py -- secrets to .env, send via Apprise, systemd service
This commit is contained in:
parent
15f6aafeeb
commit
2256adbe56
6 changed files with 400 additions and 0 deletions
21
notifier/.env.example
Normal file
21
notifier/.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# /opt/umami/notifier/.env
|
||||
# Copy this to .env and fill in all values.
|
||||
# NEVER commit .env to git -- it contains secrets.
|
||||
|
||||
# Umami
|
||||
UMAMI_URL=http://localhost:3001
|
||||
UMAMI_USER=admin
|
||||
UMAMI_PASS= # your Umami admin password
|
||||
UMAMI_SITE_ID=4d2ae2fe-0fb2-4119-879b-cda63ccbbd3f
|
||||
|
||||
# Telegram -- only used for receiving /commands (getUpdates).
|
||||
# Sending goes via Apprise. Rotate here when BotFather issues a new token.
|
||||
TELEGRAM_TOKEN= # your new rotated bot token
|
||||
TELEGRAM_CHAT_ID=897499696
|
||||
|
||||
# Apprise -- all outbound notifications route through here.
|
||||
# Format: https://USER:PASSWORD@notify.manohargupta.com/notify/apprise
|
||||
APPRISE_URL=https://manohar: # add your Apprise password after the colon
|
||||
|
||||
# Tuning
|
||||
POLL_INTERVAL=30 # seconds between Umami checks
|
||||
43
notifier/config.py
Normal file
43
notifier/config.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
config.py -- loads all configuration from .env file.
|
||||
Never import secrets directly in other modules -- always import from here.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timezone, timedelta
|
||||
|
||||
# Load .env manually (no external deps needed).
|
||||
# Reads KEY=VALUE lines, ignores comments and blank lines.
|
||||
def _load_env(path="/opt/umami/notifier/.env"):
|
||||
env_path = Path(path)
|
||||
if not env_path.exists():
|
||||
raise FileNotFoundError(f".env not found at {path}. Copy .env.example and fill in values.")
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
_load_env()
|
||||
|
||||
# Umami
|
||||
UMAMI_URL = os.environ["UMAMI_URL"]
|
||||
UMAMI_USER = os.environ["UMAMI_USER"]
|
||||
UMAMI_PASS = os.environ["UMAMI_PASS"]
|
||||
UMAMI_SITE_ID = os.environ["UMAMI_SITE_ID"]
|
||||
|
||||
# Telegram -- only needed for receiving /commands via getUpdates.
|
||||
# Sending goes through Apprise, not directly to Telegram API.
|
||||
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
|
||||
TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
|
||||
|
||||
# Apprise -- all outbound notifications go here.
|
||||
# Format: https://user:pass@notify.manohargupta.com/notify/apprise
|
||||
APPRISE_URL = os.environ["APPRISE_URL"]
|
||||
|
||||
# General
|
||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30")) # seconds
|
||||
IST = timezone(timedelta(hours=5, minutes=30))
|
||||
18
notifier/deploy.sh
Normal file
18
notifier/deploy.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/bash
|
||||
# deploy-notifier.sh -- copies refactored watcher to server
|
||||
set -e
|
||||
|
||||
SERVER="root@100.75.128.45"
|
||||
DEST="/opt/umami/notifier"
|
||||
|
||||
echo "Copying files..."
|
||||
scp /Users/manohar_air/MyProjects/deployments/notifier/config.py $SERVER:$DEST/config.py
|
||||
scp /Users/manohar_air/MyProjects/deployments/notifier/notifier.py $SERVER:$DEST/notifier.py
|
||||
scp /Users/manohar_air/MyProjects/deployments/notifier/watcher.py $SERVER:$DEST/watcher.py
|
||||
scp /Users/manohar_air/MyProjects/deployments/notifier/.env.example $SERVER:$DEST/.env.example
|
||||
|
||||
echo "Done. Now on the server:"
|
||||
echo " 1. cp $DEST/.env.example $DEST/.env"
|
||||
echo " 2. nano $DEST/.env (fill in secrets)"
|
||||
echo " 3. chmod 600 $DEST/.env"
|
||||
echo " 4. kill \$(pgrep -f watcher.py) && python3 $DEST/watcher.py &"
|
||||
41
notifier/notifier.py
Normal file
41
notifier/notifier.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
notifier.py -- single send function for all outbound notifications.
|
||||
|
||||
All modules call notify(message) -- never call Telegram or Apprise directly.
|
||||
Routing: notify() -> Apprise API -> Telegram (or any other configured channel)
|
||||
|
||||
Why Apprise instead of direct Telegram:
|
||||
- Retry logic built in (Telegram outages handled automatically)
|
||||
- Swap notification channel in Apprise UI without touching code
|
||||
- Token lives in .env + Apprise config, not scattered across scripts
|
||||
"""
|
||||
|
||||
import requests
|
||||
import config
|
||||
|
||||
|
||||
def notify(message: str) -> bool:
|
||||
"""
|
||||
Send a notification via Apprise.
|
||||
Returns True on success, False on failure.
|
||||
Apprise accepts plain text -- HTML formatting works if Telegram is configured
|
||||
with html_format in the Apprise config.
|
||||
"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
config.APPRISE_URL,
|
||||
json={"body": message},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] Apprise returned {resp.status_code}: {resp.text[:200]}")
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("[ERROR] Could not reach Apprise -- is notify.manohargupta.com reachable?")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Notification failed: {e}")
|
||||
return False
|
||||
260
notifier/watcher.py
Normal file
260
notifier/watcher.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
watcher.py -- Umami visitor watcher + Telegram command bot.
|
||||
|
||||
Sending: new visitor alerts -> notifier.py -> Apprise -> Telegram
|
||||
Receiving: /stats /today /live /help commands -> direct Telegram API (Apprise can't receive)
|
||||
|
||||
Run: python3 watcher.py
|
||||
Keep alive: managed by systemd (see watcher.service)
|
||||
"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import config
|
||||
from notifier import notify
|
||||
|
||||
# ── State (in-memory, resets on restart) ─────────────────────────────────────
|
||||
seen_sessions = set()
|
||||
_umami_auth_token = None
|
||||
_umami_token_time = 0
|
||||
last_telegram_update_id = 0
|
||||
|
||||
|
||||
# ── Umami auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_umami_token():
|
||||
"""Get a cached Umami JWT, refreshing every 30 minutes."""
|
||||
global _umami_auth_token, _umami_token_time
|
||||
now = time.time()
|
||||
if _umami_auth_token and (now - _umami_token_time) < 1800:
|
||||
return _umami_auth_token
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{config.UMAMI_URL}/api/auth/login",
|
||||
json={"username": config.UMAMI_USER, "password": config.UMAMI_PASS},
|
||||
timeout=10,
|
||||
)
|
||||
_umami_auth_token = r.json()["token"]
|
||||
_umami_token_time = now
|
||||
return _umami_auth_token
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Umami auth failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def umami_get(endpoint, params=None):
|
||||
"""Authenticated GET to Umami API."""
|
||||
token = get_umami_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{config.UMAMI_URL}/api/websites/{config.UMAMI_SITE_ID}/{endpoint}",
|
||||
params=params or {},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Umami API failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_recent_sessions(minutes=5):
|
||||
now = int(time.time()) * 1000
|
||||
start = now - (minutes * 60 * 1000)
|
||||
data = umami_get("sessions", {"startAt": start, "endAt": now})
|
||||
return data.get("data", []) if data else []
|
||||
|
||||
|
||||
# ── Notification formatting ───────────────────────────────────────────────────
|
||||
|
||||
def format_visitor(session):
|
||||
device_map = {
|
||||
"mobile": "\U0001f4f1", "tablet": "\U0001f4f1",
|
||||
"laptop": "\U0001f4bb", "desktop": "\U0001f5a5\ufe0f"
|
||||
}
|
||||
emoji = device_map.get(session.get("device", ""), "\U0001f310")
|
||||
city = session.get("city", "Unknown")
|
||||
country = session.get("country", "??")
|
||||
browser = session.get("browser", "Unknown").title()
|
||||
os_name = session.get("os", "Unknown")
|
||||
device = session.get("device", "Unknown").title()
|
||||
views = session.get("views", 1)
|
||||
first_at = session.get("firstAt") or session.get("createdAt") or ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(first_at.replace("Z", "+00:00"))
|
||||
time_str = dt.astimezone(config.IST).strftime("%I:%M %p IST")
|
||||
except Exception:
|
||||
time_str = "now"
|
||||
return "\n".join([
|
||||
f"{emoji} New Visitor!",
|
||||
f"\U0001f4cd {city}, {country}",
|
||||
f"\U0001f5a5\ufe0f {browser} / {os_name} / {device}",
|
||||
f"\U0001f4c4 {views} page(s) viewed",
|
||||
f"\U0001f550 {time_str}",
|
||||
])
|
||||
|
||||
|
||||
# ── Telegram command handlers ─────────────────────────────────────────────────
|
||||
# These use notify() for sending, but must poll Telegram directly for receiving.
|
||||
|
||||
def cmd_live(chat_id):
|
||||
data = umami_get("active")
|
||||
if not data:
|
||||
notify("\u26a0\ufe0f Could not fetch active visitors.")
|
||||
return
|
||||
count = data.get("visitors", 0)
|
||||
if count == 0:
|
||||
notify("\U0001f634 No active visitors right now.")
|
||||
return
|
||||
sessions = get_recent_sessions(minutes=5)
|
||||
lines = [f"\U0001f7e2 {count} active visitor(s)\n"]
|
||||
for s in sessions[:10]:
|
||||
city = s.get("city", "?")
|
||||
country = s.get("country", "?")
|
||||
device = s.get("device", "?").title()
|
||||
views = s.get("views", 1)
|
||||
first_at = s.get("firstAt") or s.get("createdAt") or ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(first_at.replace("Z", "+00:00"))
|
||||
t = dt.astimezone(config.IST).strftime("%I:%M %p")
|
||||
except Exception:
|
||||
t = "?"
|
||||
lines.append(f" \U0001f4cd {city}, {country} | {device} | {views} pages | {t} IST")
|
||||
notify("\n".join(lines))
|
||||
|
||||
|
||||
def cmd_stats(chat_id):
|
||||
now_ms = int(time.time()) * 1000
|
||||
today_start = int(datetime.now(timezone.utc).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
).timestamp()) * 1000
|
||||
stats = umami_get("stats", {"startAt": today_start, "endAt": now_ms})
|
||||
active = umami_get("active")
|
||||
if not stats:
|
||||
notify("\u26a0\ufe0f Could not fetch stats.")
|
||||
return
|
||||
pv = stats.get("pageviews", {}).get("value", 0)
|
||||
visitors = stats.get("visitors", {}).get("value", 0)
|
||||
visits = stats.get("visits", {}).get("value", 0)
|
||||
bounce = stats.get("bounces", {}).get("value", 0)
|
||||
total_time = stats.get("totaltime", {}).get("value", 0)
|
||||
avg_time = round(total_time / visits, 1) if visits else 0
|
||||
live = active.get("visitors", 0) if active else 0
|
||||
notify("\n".join([
|
||||
"\U0001f4ca Today's Stats",
|
||||
f"\U0001f465 Visitors: {visitors}",
|
||||
f"\U0001f440 Pageviews: {pv}",
|
||||
f"\U0001f6aa Visits: {visits}",
|
||||
f"\U0001f3c3 Bounces: {bounce}",
|
||||
f"\u23f1\ufe0f Avg time: {avg_time}s",
|
||||
f"\U0001f7e2 Live now: {live}",
|
||||
]))
|
||||
|
||||
|
||||
def cmd_today(chat_id):
|
||||
now_ms = int(time.time()) * 1000
|
||||
today_start = int(datetime.now(timezone.utc).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
).timestamp()) * 1000
|
||||
pages = umami_get("metrics", {"startAt": today_start, "endAt": now_ms, "type": "url"})
|
||||
referrers = umami_get("metrics", {"startAt": today_start, "endAt": now_ms, "type": "referrer"})
|
||||
lines = ["\U0001f4c4 Today's Top Pages\n"]
|
||||
for p in (pages or [])[:8]:
|
||||
lines.append(f" {p.get('x','?')} -- {p.get('y',0)} views")
|
||||
if not pages:
|
||||
lines.append(" No data yet")
|
||||
lines.append("\n\U0001f517 Top Referrers\n")
|
||||
for r in (referrers or [])[:5]:
|
||||
ref = r.get("x") or "(direct)"
|
||||
lines.append(f" {ref} -- {r.get('y',0)}")
|
||||
if not referrers:
|
||||
lines.append(" No referrer data yet")
|
||||
notify("\n".join(lines))
|
||||
|
||||
|
||||
def cmd_help(chat_id):
|
||||
notify("\n".join([
|
||||
"\U0001f916 Portfolio Watcher Commands",
|
||||
"/stats -- Today's summary",
|
||||
"/today -- Top pages and referrers",
|
||||
"/live -- Active visitors now",
|
||||
"/help -- This message",
|
||||
"\nNew visitor alerts sent automatically.",
|
||||
]))
|
||||
|
||||
|
||||
COMMANDS = {
|
||||
"/stats": cmd_stats,
|
||||
"/today": cmd_today,
|
||||
"/live": cmd_live,
|
||||
"/help": cmd_help,
|
||||
"/start": cmd_help,
|
||||
}
|
||||
|
||||
|
||||
# ── Telegram command polling ──────────────────────────────────────────────────
|
||||
# Must stay direct -- Apprise is send-only, cannot poll getUpdates.
|
||||
|
||||
def poll_telegram_commands():
|
||||
global last_telegram_update_id
|
||||
try:
|
||||
r = requests.get(
|
||||
f"https://api.telegram.org/bot{config.TELEGRAM_TOKEN}/getUpdates",
|
||||
params={"offset": last_telegram_update_id + 1, "timeout": 0},
|
||||
timeout=5,
|
||||
)
|
||||
for update in r.json().get("result", []):
|
||||
last_telegram_update_id = update["update_id"]
|
||||
msg = update.get("message", {})
|
||||
chat_id = str(msg.get("chat", {}).get("id", ""))
|
||||
text = msg.get("text", "").strip().lower().split("@")[0]
|
||||
if chat_id == config.TELEGRAM_CHAT_ID and text in COMMANDS:
|
||||
print(f"[CMD] {text} from {chat_id}")
|
||||
COMMANDS[text](chat_id)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Telegram poll failed: {e}")
|
||||
|
||||
|
||||
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
global seen_sessions
|
||||
print(f"[INFO] Watcher started. Polling every {config.POLL_INTERVAL}s...")
|
||||
notify("\U0001f7e2 Portfolio Watcher is now active!\nSend /help for commands.")
|
||||
|
||||
# Seed seen sessions so we don't spam on startup
|
||||
initial = get_recent_sessions()
|
||||
seen_sessions = {s["id"] for s in initial}
|
||||
print(f"[INFO] Seeded {len(seen_sessions)} existing sessions")
|
||||
|
||||
# Drain any queued Telegram commands before entering the loop
|
||||
poll_telegram_commands()
|
||||
|
||||
while True:
|
||||
try:
|
||||
poll_telegram_commands()
|
||||
|
||||
for session in get_recent_sessions():
|
||||
sid = session["id"]
|
||||
if sid not in seen_sessions:
|
||||
seen_sessions.add(sid)
|
||||
notify(format_visitor(session))
|
||||
print(f"[NOTIFY] New visitor: {session.get('city')}, {session.get('country')}")
|
||||
|
||||
# Prevent unbounded memory growth
|
||||
if len(seen_sessions) > 500:
|
||||
seen_sessions = set(list(seen_sessions)[-200:])
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Loop error: {e}")
|
||||
|
||||
time.sleep(config.POLL_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
17
notifier/watcher.service
Normal file
17
notifier/watcher.service
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[Unit]
|
||||
Description=Portfolio Watcher -- Umami visitor alerts via Apprise
|
||||
After=network.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/umami/notifier
|
||||
ExecStart=/usr/bin/python3 /opt/umami/notifier/watcher.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
# Logs go to journald -- view with: journalctl -u watcher -f
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Add table
Reference in a new issue