agnes-the-ai-analyst/services/telegram_bot/bot.py
Vojtech 38f6b639d2
feat(observability): request_id end-to-end + dev debug toolbar + centralized logging (#136)
Cuts release 0.20.0.

## Highlights
- X-Request-ID header on every response + sanitized to [A-Za-z0-9_-] (CRLF log-forging mitigation)
- Error pages (HTML + JSON 500) surface request_id for support tickets
- Dev debug toolbar gated by DEBUG=1 — fastapi-debug-toolbar with custom DuckDBPanel
- Centralized app.logging_config.setup_logging() replaces 23 scattered basicConfig calls
- Telegram bot drops bot.log file — stdout only (BREAKING)

## Devin findings addressed
- BUG_0001: .env.template no longer claims FastAPI debug=True
- BUG_0002: subprocess extractor logs INFO to stderr again
- ANALYSIS_0003: _wants_html no longer matches Accept: */* (curl gets JSON as before)
- BUG on b1c6ee9: HTML 500 page no longer leaks str(exc) in production
- BUG on b13d2fe: 2 CLAUDE.md compliance flags (transform.py + ws_gateway) accepted as scope-limited logging refactor — follow-up to update CLAUDE.md if needed

See CHANGELOG [0.20.0] for full notes.
2026-04-29 22:54:21 +02:00

345 lines
11 KiB
Python

"""
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())