lazyflat/web/notifications.py
EiSiMo 81d6b65eae feat(notifications): new match format with Gmaps + lazyflat deep-link
New Telegram match layout:
  Karl-Ziegler-Straße 7           (linked → Google Maps)
  12489 Treptow-Köpenick
  Miete: 944.12 (18.51 €/m²)
  Fläche: 51.0
  Zimmer: 2.0
  WBS: nicht erforderlich

  Zur original Anzeige            (→ flat URL)
  Zur lazyflat Seite              (→ /?flat=<id>)

Deep-link behavior on lazyflat: ?flat=<id> expands the matching row,
scrolls it into view, and pulses a yellow highlight for 3s. The query
param is stripped from history afterwards so reload stays clean.
Unknown flat IDs drop the param silently.

Helpers: _address_lines splits the scraper's "Street, PLZ, District"
into two display lines; _gmaps_url falls back to a maps.google query
when the payload has no explicit link; _wbs_label normalises the
German WBS variants to "erforderlich" / "nicht erforderlich".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:04 +02:00

158 lines
5.5 KiB
Python

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