Telegram workout tracker bot with Mini App web UI, SQLite database, API server, and cloudflared tunnel support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
5.3 KiB
Python
188 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Orchestrator — the single entry point for `nix run`.
|
|
1. Loads BOT_TOKEN from .env in the current directory
|
|
2. Starts the API server
|
|
3. Starts a localtunnel to get a public HTTPS URL
|
|
4. Starts the Telegram bot with that URL
|
|
5. Cleans up everything on Ctrl+C
|
|
"""
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import pathlib
|
|
|
|
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
|
|
|
|
def load_dotenv():
|
|
"""Load .env from the working directory (where the user ran `nix run`)."""
|
|
env_file = pathlib.Path.cwd() / ".env"
|
|
if not env_file.exists():
|
|
# Also check next to the script (for non-nix usage)
|
|
env_file = SCRIPT_DIR / ".env"
|
|
if not env_file.exists():
|
|
return
|
|
|
|
print(f"Loading secrets from {env_file}")
|
|
with open(env_file) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" in line:
|
|
key, _, value = line.partition("=")
|
|
key = key.strip()
|
|
value = value.strip().strip("\"'")
|
|
os.environ.setdefault(key, value)
|
|
|
|
|
|
def check_token():
|
|
token = os.environ.get("BOT_TOKEN", "")
|
|
if not token or token == "YOUR_BOT_TOKEN_HERE":
|
|
print("\n No BOT_TOKEN found!\n")
|
|
print(" Create a .env file in this directory with:")
|
|
print(" BOT_TOKEN=your-token-from-@BotFather\n")
|
|
sys.exit(1)
|
|
# Mask token in logs
|
|
masked = token[:5] + "..." + token[-4:]
|
|
print(f" BOT_TOKEN: {masked}")
|
|
return token
|
|
|
|
|
|
def start_server(port: int) -> subprocess.Popen:
|
|
"""Start the aiohttp API server."""
|
|
env = {**os.environ, "API_PORT": str(port)}
|
|
return subprocess.Popen(
|
|
[sys.executable, str(SCRIPT_DIR / "server.py")],
|
|
env=env,
|
|
cwd=pathlib.Path.cwd(), # DB writes go to user's working directory
|
|
)
|
|
|
|
|
|
def start_tunnel(port: int) -> tuple[subprocess.Popen, str]:
|
|
"""Start localtunnel and return (process, public_url)."""
|
|
print(f" Starting tunnel to port {port}...")
|
|
|
|
proc = subprocess.Popen(
|
|
["lt", "--port", str(port)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
|
|
# localtunnel prints "your url is: https://xxx.loca.lt"
|
|
url = None
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
line = proc.stdout.readline()
|
|
if not line:
|
|
if proc.poll() is not None:
|
|
print(" Tunnel process exited unexpectedly.")
|
|
break
|
|
continue
|
|
line = line.strip()
|
|
print(f" [tunnel] {line}")
|
|
match = re.search(r"https?://\S+", line)
|
|
if match:
|
|
url = match.group(0)
|
|
break
|
|
|
|
if not url:
|
|
proc.kill()
|
|
print("\n Could not get a tunnel URL.")
|
|
print(" Make sure localtunnel is working: lt --port 8080\n")
|
|
sys.exit(1)
|
|
|
|
return proc, url
|
|
|
|
|
|
def start_bot(webapp_url: str) -> subprocess.Popen:
|
|
"""Start the Telegram bot with the tunnel URL."""
|
|
env = {**os.environ, "WEBAPP_URL": webapp_url}
|
|
return subprocess.Popen(
|
|
[sys.executable, str(SCRIPT_DIR / "bot.py")],
|
|
env=env,
|
|
cwd=pathlib.Path.cwd(),
|
|
)
|
|
|
|
|
|
def main():
|
|
port = int(os.environ.get("API_PORT", "8080"))
|
|
procs: list[subprocess.Popen] = []
|
|
|
|
def cleanup(sig=None, frame=None):
|
|
print("\nShutting down...")
|
|
for p in procs:
|
|
try:
|
|
p.terminate()
|
|
except OSError:
|
|
pass
|
|
for p in procs:
|
|
try:
|
|
p.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
p.kill()
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, cleanup)
|
|
signal.signal(signal.SIGTERM, cleanup)
|
|
|
|
print()
|
|
print(" ==========================================")
|
|
print(" Telegram Fitness Bot")
|
|
print(" ==========================================")
|
|
print()
|
|
|
|
# 1. Load .env
|
|
load_dotenv()
|
|
check_token()
|
|
|
|
# 2. Database will be created in the working directory
|
|
db_path = os.environ.setdefault("DB_PATH", str(pathlib.Path.cwd() / "fitness.db"))
|
|
print(f" Database: {db_path}")
|
|
|
|
# 3. Start API server
|
|
print(f"\n Starting API server on port {port}...")
|
|
server = start_server(port)
|
|
procs.append(server)
|
|
time.sleep(1) # Give it a moment to bind
|
|
|
|
if server.poll() is not None:
|
|
print(" Server failed to start!")
|
|
sys.exit(1)
|
|
|
|
# 4. Start tunnel
|
|
tunnel, webapp_url = start_tunnel(port)
|
|
procs.append(tunnel)
|
|
|
|
# 5. Start bot
|
|
print(f"\n WEBAPP_URL: {webapp_url}")
|
|
print(f" Starting bot...\n")
|
|
bot = start_bot(webapp_url)
|
|
procs.append(bot)
|
|
|
|
print(" ==========================================")
|
|
print(f" All systems go!")
|
|
print(f" Mini App: {webapp_url}")
|
|
print(f" API: http://localhost:{port}")
|
|
print(f" Press Ctrl+C to stop")
|
|
print(" ==========================================")
|
|
print()
|
|
|
|
# Wait for any process to exit
|
|
while True:
|
|
for p in procs:
|
|
ret = p.poll()
|
|
if ret is not None:
|
|
name = {id(server): "Server", id(tunnel): "Tunnel", id(bot): "Bot"}.get(id(p), "?")
|
|
print(f"\n {name} exited with code {ret}")
|
|
cleanup()
|
|
time.sleep(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|