""" User-level notification dispatcher. Channels: - 'ui' → no-op (dashboard shows the events anyway) - 'telegram' → per-user bot token + chat id """ import logging from typing import Optional from urllib.parse import quote import requests import db from settings import PUBLIC_URL logger = logging.getLogger("web.notifications") EventType = str # 'match' | 'apply_ok' | 'apply_fail' def telegram_send(token: str, chat_id: str, text: str) -> tuple[bool, str]: """Send a Telegram message. Returns (ok, detail) — detail is the Telegram API's `description` on failure, or a short error string, or "ok".""" try: r = requests.post( f"https://api.telegram.org/bot{token}/sendMessage", json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown", "disable_web_page_preview": True}, timeout=10, ) if r.ok: return True, "ok" try: detail = r.json().get("description") or f"HTTP {r.status_code}" except ValueError: detail = f"HTTP {r.status_code}" logger.warning("telegram send failed: %s %s", r.status_code, r.text[:200]) return False, detail except requests.RequestException as e: logger.warning("telegram unreachable: %s", e) return False, str(e) def _should_notify(notif, event: EventType) -> bool: if event == "match": return bool(notif["notify_on_match"]) if event == "apply_ok": return bool(notif["notify_on_apply_success"]) if event == "apply_fail": return bool(notif["notify_on_apply_fail"]) return False def notify_user(user_id: int, event: EventType, *, subject: str, body_plain: str, body_markdown: Optional[str] = None) -> None: """Fire a notification for one user on one event. Best-effort, non-raising.""" try: notif = db.get_notifications(user_id) if not notif or not _should_notify(notif, event): return channel = notif["channel"] or "ui" if channel == "telegram": token = notif["telegram_bot_token"] chat = notif["telegram_chat_id"] if token and chat: telegram_send(token, chat, body_markdown or body_plain) except Exception: logger.exception("notify_user failed for user=%s event=%s", user_id, event) # -- Convenience builders ----------------------------------------------------- def _address_lines(address: str) -> tuple[str, str]: """Split "Street Nr, PLZ, District" into (line1, line2) for the two-line layout in notifications. Falls back gracefully for unexpected shapes.""" parts = [p.strip() for p in (address or "").split(",") if p.strip()] if len(parts) >= 3: return parts[0], f"{parts[1]} {', '.join(parts[2:])}" if len(parts) == 2: return parts[0], parts[1] return (address or "").strip(), "" def _gmaps_url(address: str, lat, lng) -> str: if lat is not None and lng is not None: return f"https://www.google.com/maps/search/?api=1&query={lat},{lng}" return f"https://www.google.com/maps/search/?api=1&query={quote(address or '')}" def _wbs_label(wbs: str) -> str: w = (wbs or "").strip().lower() if w == "erforderlich": return "erforderlich" if w in ("nicht erforderlich", "kein", "nein", "no", "ohne", "-", ""): return "nicht erforderlich" return wbs # pass through unrecognised literals def _fmt_num(v) -> str: return "—" if v is None else f"{v}" def on_match(user_id: int, flat: dict) -> None: address = (flat.get("address") or "").strip() line1, line2 = _address_lines(address) link = flat.get("link", "") rent = flat.get("total_rent") sqm_price = flat.get("sqm_price") rooms = flat.get("rooms") size = flat.get("size") wbs_txt = _wbs_label(flat.get("wbs", "")) gmaps = flat.get("address_link_gmaps") or _gmaps_url( address, flat.get("lat"), flat.get("lng"), ) flat_id = str(flat.get("id", "")) deep_link = f"{PUBLIC_URL}/?flat={quote(flat_id, safe='')}" rent_str = _fmt_num(rent) if sqm_price: rent_str += f" ({sqm_price} €/m²)" body = ( f"{line1}\n{line2}\n" f"Miete: {rent_str}\n" f"Fläche: {_fmt_num(size)}\n" f"Zimmer: {_fmt_num(rooms)}\n" f"WBS: {wbs_txt}\n\n" f"Original: {link}\n" f"lazyflat: {deep_link}" ) md = ( f"[{line1}]({gmaps})\n{line2}\n" f"Miete: {rent_str}\n" f"Fläche: {_fmt_num(size)}\n" f"Zimmer: {_fmt_num(rooms)}\n" f"WBS: {wbs_txt}\n\n" f"[Zur original Anzeige]({link})\n" f"[Zur lazyflat Seite]({deep_link})" ) notify_user(user_id, "match", subject="[lazyflat] passende Wohnung", body_plain=body, body_markdown=md) def on_apply_ok(user_id: int, flat: dict, message: str) -> None: addr = flat.get("address") or flat.get("link") body = f"Bewerbung erfolgreich: {addr}\n{message}" md = f"*Bewerbung erfolgreich*\n{addr}\n{message}" notify_user(user_id, "apply_ok", subject="[lazyflat] Bewerbung OK", body_plain=body, body_markdown=md) def on_apply_fail(user_id: int, flat: dict, message: str) -> None: addr = flat.get("address") or flat.get("link") body = f"Bewerbung fehlgeschlagen: {addr}\n{message}\n{PUBLIC_URL}/bewerbungen" md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n" f"[Bewerbungen ansehen]({PUBLIC_URL}/bewerbungen)") notify_user(user_id, "apply_fail", subject="[lazyflat] Bewerbung fehlgeschlagen", body_plain=body, body_markdown=md)