agnes-the-ai-analyst/services/telegram_bot/storage.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

146 lines
4.1 KiB
Python

"""
JSON file storage for Telegram user mappings and pending verification codes.
Thread-safe file operations with atomic writes.
"""
import json
import logging
import os
import random
import string
import tempfile
import time
from . import config
logger = logging.getLogger(__name__)
def _read_json(path: str) -> dict:
"""Read a JSON file, return empty dict if not found or invalid."""
try:
with open(path, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _write_json(path: str, data: dict) -> None:
"""Atomically write JSON data to file."""
dir_path = os.path.dirname(path)
os.makedirs(dir_path, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
os.chmod(tmp_path, 0o660) # group-readable for data-ops (webapp)
os.replace(tmp_path, path)
except Exception:
os.unlink(tmp_path)
raise
# --- Telegram Users ---
def get_chat_id(username: str) -> int | None:
"""Get Telegram chat_id for a linked username."""
users = _read_json(config.TELEGRAM_USERS_FILE)
entry = users.get(username)
if entry:
return entry.get("chat_id")
return None
def get_username_by_chat_id(chat_id: int) -> str | None:
"""Reverse lookup: get username for a Telegram chat_id."""
users = _read_json(config.TELEGRAM_USERS_FILE)
for username, entry in users.items():
if entry.get("chat_id") == chat_id:
return username
return None
def link_user(username: str, chat_id: int) -> None:
"""Link a username to a Telegram chat_id."""
users = _read_json(config.TELEGRAM_USERS_FILE)
users[username] = {
"chat_id": chat_id,
"linked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
_write_json(config.TELEGRAM_USERS_FILE, users)
logger.info(f"Linked user '{username}' to chat_id {chat_id}")
def unlink_user(username: str) -> bool:
"""Unlink a username from Telegram. Returns True if was linked."""
users = _read_json(config.TELEGRAM_USERS_FILE)
if username in users:
del users[username]
_write_json(config.TELEGRAM_USERS_FILE, users)
logger.info(f"Unlinked user '{username}'")
return True
return False
def get_user_status(username: str) -> dict | None:
"""Get link status for a username. Returns dict with linked_at or None."""
users = _read_json(config.TELEGRAM_USERS_FILE)
return users.get(username)
# --- Verification Codes ---
def _generate_code() -> str:
"""Generate a random numeric verification code."""
return "".join(random.choices(string.digits, k=config.CODE_LENGTH))
def _cleanup_expired(codes: dict) -> dict:
"""Remove expired codes."""
now = time.time()
return {code: data for code, data in codes.items() if now - data.get("created_at", 0) < config.CODE_TTL_SECONDS}
def create_verification_code(chat_id: int) -> str:
"""Create a new verification code for a Telegram chat_id."""
codes = _read_json(config.PENDING_CODES_FILE)
codes = _cleanup_expired(codes)
# Remove any existing code for this chat_id
codes = {code: data for code, data in codes.items() if data.get("chat_id") != chat_id}
code = _generate_code()
# Ensure uniqueness
while code in codes:
code = _generate_code()
codes[code] = {
"chat_id": chat_id,
"created_at": time.time(),
}
_write_json(config.PENDING_CODES_FILE, codes)
logger.info(f"Created verification code for chat_id {chat_id}")
return code
def verify_code(code: str) -> int | None:
"""Verify a code and return the chat_id if valid. Consumes the code."""
codes = _read_json(config.PENDING_CODES_FILE)
codes = _cleanup_expired(codes)
data = codes.get(code)
if data is None:
return None
chat_id = data["chat_id"]
# Consume the code
del codes[code]
_write_json(config.PENDING_CODES_FILE, codes)
logger.info(f"Verified code for chat_id {chat_id}")
return chat_id