- sqm_price now renders with 2 decimals + German comma separator (e.g. "(12,35 €/m²)" instead of "(12 €/m²)"). - Map link covers BOTH address lines, not just the street. Telegram Markdown can't span link text across newlines, so each line gets its own [text](gmaps_url) — same URL, both lines blue + clickable, layout stays two-line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.9 KiB
Python
165 lines
5.9 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:
|
|
# German-style decimal separator, rounded to the cent.
|
|
rent_str += f" ({sqm_price:.2f} €/m²)".replace(".", ",")
|
|
|
|
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}"
|
|
)
|
|
# Telegram Markdown can't span link text across newlines, so emit two
|
|
# separate links that share the same Google Maps URL — both lines render
|
|
# blue + clickable, layout stays two-line.
|
|
addr_md = f"[{line1}]({gmaps})"
|
|
if line2:
|
|
addr_md += f"\n[{line2}]({gmaps})"
|
|
md = (
|
|
f"{addr_md}\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)
|