""" Telegram Notification Bot - main entry point. Runs two concurrent tasks: 1. Telegram polling - handles /start commands, generates verification codes 2. HTTP server on unix socket - accepts send requests from notify-runner Usage: python -m telegram_bot.bot """ import asyncio import grp import json import logging import os import sys from aiohttp import web from app.logging_config import setup_logging from . import config from .dispatch import dispatch_to_ws_gateway from .runner import run_user_script from .sender import ( answer_callback_query, get_updates, send_message, send_message_with_buttons, send_photo, ) from .status import get_notification_status, get_script_buttons from .storage import create_verification_code, get_chat_id, get_username_by_chat_id from .test_report import generate_test_report # Load instance branding for bot messages try: from config.loader import load_instance_config, get_instance_value _bot_config = load_instance_config() _bot_instance_name = get_instance_value(_bot_config, "instance", "name", default="Data Analyst") _bot_server_hostname = get_instance_value(_bot_config, "server", "hostname", default="your-server") _bot_domain_suffix = get_instance_value(_bot_config, "telegram", "domain_suffix", default="") except Exception: _bot_instance_name = "Data Analyst" _bot_server_hostname = "your-server" _bot_domain_suffix = "" # Configure logging setup_logging(__name__) logger = logging.getLogger("notify-bot") # --- Telegram Polling --- async def handle_message(message: dict) -> None: """Handle an incoming Telegram message.""" chat_id = message.get("chat", {}).get("id") text = message.get("text", "").strip() if not chat_id: return if text == "/start": username = get_username_by_chat_id(chat_id) if username: await send_message( chat_id, f"You are already linked as *{username}*.\nUse /help to see available commands.", ) return code = create_verification_code(chat_id) await send_message( chat_id, f"Welcome to {_bot_instance_name} Notifications!\n\n" f"Your verification code: *{code}*\n\n" f"Enter this code on your dashboard at {_bot_server_hostname}\n" f"Code expires in 10 minutes.", ) logger.info(f"Sent verification code to chat_id {chat_id}") elif text == "/whoami": username = get_username_by_chat_id(chat_id) if username: # Username is derived from email if _bot_domain_suffix: email = f"{username}@{_bot_domain_suffix}" else: email = username await send_message( chat_id, f"*{username}*\n{email}", ) else: await send_message( chat_id, "No linked account. Use /start to link.", parse_mode="", ) elif text == "/status": username = get_username_by_chat_id(chat_id) if not username: await send_message( chat_id, "Link your account first using /start and the dashboard.", parse_mode="", ) return status_text = get_notification_status(username) buttons = get_script_buttons(username) if buttons: await send_message_with_buttons(chat_id, status_text, buttons) else: await send_message(chat_id, status_text) elif text == "/test": username = get_username_by_chat_id(chat_id) if not username: await send_message( chat_id, "Link your account first using /start and the dashboard.", parse_mode="", ) return await send_message(chat_id, "Generating test report...", parse_mode="") try: image_path, caption = generate_test_report(username) await send_photo(chat_id, image_path, caption) # Cleanup temp file os.unlink(image_path) logger.info(f"Sent test report to {username}") except Exception: logger.exception(f"Failed to generate test report for {username}") await send_message(chat_id, "Failed to generate report. Check server logs.", parse_mode="") elif text == "/help": await send_message( chat_id, f"*{_bot_instance_name} Bot*\n\n" "/start - Link your Telegram account\n" "/whoami - Show your username and chat ID\n" "/status - List your notification scripts\n" "/test - Send a demo report\n" "/help - Show this help", ) else: await send_message( chat_id, "Unknown command. Type /help for available commands.", parse_mode="", ) async def handle_callback_query(callback_query: dict) -> None: """Handle inline keyboard button press.""" callback_id = callback_query.get("id") chat_id = callback_query.get("message", {}).get("chat", {}).get("id") data = callback_query.get("data", "") if not chat_id or not data: return # Parse callback data: "run:{script_name}" if not data.startswith("run:"): await answer_callback_query(callback_id, "Unknown action") return script_name = data[4:] # strip "run:" username = get_username_by_chat_id(chat_id) if not username: await answer_callback_query(callback_id, "Account not linked") return await answer_callback_query(callback_id, f"Running {script_name}...") await send_message(chat_id, f"Running `{script_name}`...", parse_mode="Markdown") logger.info(f"On-demand run: {script_name} for {username}") output = await asyncio.to_thread(run_user_script, username, script_name) if output is None: await send_message(chat_id, f"`{script_name}` failed. Check server logs.", parse_mode="Markdown") return if not output.get("notify", False): await send_message(chat_id, f"`{script_name}` returned notify=false (no alert).", parse_mode="Markdown") return # Format and send the notification parts = [] title = output.get("title", "") message_text = output.get("message", "") if title: parts.append(f"*{title}*") if message_text: parts.append(message_text) text = "\n".join(parts) image_path = output.get("image_path", "") if image_path and os.path.isfile(image_path): await send_photo(chat_id, image_path, caption=text) elif text: await send_message(chat_id, text) else: await send_message(chat_id, f"`{script_name}` produced no output.", parse_mode="Markdown") # Also dispatch to WebSocket gateway for desktop app await asyncio.to_thread(dispatch_to_ws_gateway, username, output, script_name) async def polling_loop() -> None: """Long-poll Telegram for updates.""" logger.info("Starting Telegram polling loop") offset = 0 while True: try: updates, offset = await get_updates(offset) for update in updates: message = update.get("message") if message: await handle_message(message) callback_query = update.get("callback_query") if callback_query: await handle_callback_query(callback_query) except Exception: logger.exception("Polling loop error") await asyncio.sleep(config.POLL_ERROR_RETRY_SECONDS) # --- HTTP Send API (unix socket) --- async def handle_send(request: web.Request) -> web.Response: """Handle POST /send - send text message.""" try: data = await request.json() except json.JSONDecodeError: return web.json_response({"error": "Invalid JSON"}, status=400) username = data.get("user") text = data.get("text") parse_mode = data.get("parse_mode", "Markdown") if not username or not text: return web.json_response({"error": "Missing required fields: user, text"}, status=400) chat_id = get_chat_id(username) if not chat_id: return web.json_response({"error": f"User '{username}' has no linked Telegram"}, status=404) success = await send_message(chat_id, text, parse_mode) if success: return web.json_response({"ok": True}) return web.json_response({"error": "Failed to send message"}, status=502) async def handle_send_photo(request: web.Request) -> web.Response: """Handle POST /send_photo - send image with optional caption.""" try: data = await request.json() except json.JSONDecodeError: return web.json_response({"error": "Invalid JSON"}, status=400) username = data.get("user") photo_path = data.get("photo_path") caption = data.get("caption", "") parse_mode = data.get("parse_mode", "Markdown") if not username or not photo_path: return web.json_response({"error": "Missing required fields: user, photo_path"}, status=400) if not os.path.isfile(photo_path): return web.json_response({"error": f"Photo file not found: {photo_path}"}, status=400) chat_id = get_chat_id(username) if not chat_id: return web.json_response({"error": f"User '{username}' has no linked Telegram"}, status=404) success = await send_photo(chat_id, photo_path, caption, parse_mode) if success: return web.json_response({"ok": True}) return web.json_response({"error": "Failed to send photo"}, status=502) async def handle_health(request: web.Request) -> web.Response: """Health check endpoint.""" return web.json_response({"status": "ok"}) def create_app() -> web.Application: """Create the aiohttp application.""" app = web.Application() app.router.add_post("/send", handle_send) app.router.add_post("/send_photo", handle_send_photo) app.router.add_get("/health", handle_health) return app async def start_http_server() -> None: """Start HTTP server on unix socket.""" # Remove stale socket socket_path = config.SOCKET_PATH if os.path.exists(socket_path): os.unlink(socket_path) app = create_app() runner = web.AppRunner(app) await runner.setup() site = web.UnixSite(runner, socket_path) await site.start() # Set socket permissions: group-writable for dataread group (analysts send via notify-runner) # Socket lives in /run/notify-bot/ (systemd RuntimeDirectory, mode 0755) os.chmod(socket_path, 0o660) # Change group ownership to dataread (deploy user is member of dataread group) os.chown(socket_path, -1, grp.getgrnam("dataread").gr_gid) logger.info(f"HTTP server listening on {socket_path}") # --- Main --- async def main() -> None: """Run bot polling and HTTP server concurrently.""" if not config.TELEGRAM_BOT_TOKEN: logger.error("TELEGRAM_BOT_TOKEN is not set. Exiting.") sys.exit(1) # Ensure notifications directory exists os.makedirs(config.NOTIFICATIONS_DIR, exist_ok=True) await start_http_server() await polling_loop() if __name__ == "__main__": asyncio.run(main())