feat(agent-office): 매물 알림 텔레그램 notify-listing(너+아내)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
@@ -227,6 +227,24 @@ async def realestate_notify(body: RealestateNotifyBody):
|
||||
return await agent.on_new_matches(body.matches)
|
||||
|
||||
|
||||
# --- Realestate Listing Notify Endpoint (매물 알림, 청약 notify와 별도) ---
|
||||
|
||||
class ListingNotifyBody(BaseModel):
|
||||
listings: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
@app.post("/api/agent-office/realestate/notify-listing")
|
||||
async def realestate_notify_listing(body: ListingNotifyBody):
|
||||
from .notifiers.telegram_realestate_listing import send_listing_alerts
|
||||
from .db import add_log
|
||||
res = await send_listing_alerts(body.listings)
|
||||
for a in body.listings:
|
||||
add_log("realestate",
|
||||
f"매물알림 {a.get('deal_type')} {a.get('complex_name')} "
|
||||
f"{a.get('safety_tier') or a.get('valuation_tier')}", "info")
|
||||
return res
|
||||
|
||||
|
||||
# --- YouTube Research Agent Endpoints ---
|
||||
|
||||
class YouTubeResearchBody(BaseModel):
|
||||
|
||||
83
agent-office/app/notifiers/telegram_realestate_listing.py
Normal file
83
agent-office/app/notifiers/telegram_realestate_listing.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""매물 알림 텔레그램 포맷+전송 (본인+아내 각각). realestate-lab notify_new_listings 수신.
|
||||
|
||||
telegram_trade.py(매매알람)와 대칭 구조: send_raw 저수준 전송 + chat_id 리스트 순회.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..telegram.messaging import send_raw
|
||||
from ..config import TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID
|
||||
|
||||
logger = logging.getLogger("agent-office")
|
||||
|
||||
_TIER_EMOJI = {
|
||||
"안전": "🟢 안전", "주의": "🟡 주의", "위험": "🔴 위험", "보류": "⚪ 보류(표본부족)",
|
||||
"저평가": "🟢 저평가", "시세": "🟡 시세 수준", "고가": "🔴 고가",
|
||||
}
|
||||
|
||||
|
||||
def _manwon(v) -> str:
|
||||
if not v:
|
||||
return "-"
|
||||
return f"{v / 10000:.1f}억" if v >= 10000 else f"{v:,}만"
|
||||
|
||||
|
||||
def format_listing_alert(a: Dict[str, Any]) -> str:
|
||||
cat = a.get("category") or ("매매" if a.get("deal_type") == "매매" else "임차")
|
||||
if cat == "임차":
|
||||
price = _manwon(a.get("deposit"))
|
||||
tier = _TIER_EMOJI.get(a.get("safety_tier"), a.get("safety_tier") or "")
|
||||
ratio = a.get("jeonse_ratio")
|
||||
if ratio is not None:
|
||||
judge = (f"{tier} (전세가율 {int(ratio * 100)}% · 시세 {_manwon(a.get('market_median'))}, "
|
||||
f"실거래 {a.get('sample_size', 0)}건)")
|
||||
else:
|
||||
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
|
||||
warn = "⚠️ 등기부 선순위 근저당 수동 확인 필수(인터넷등기소)"
|
||||
else:
|
||||
price = _manwon(a.get("sale_price"))
|
||||
tier = _TIER_EMOJI.get(a.get("valuation_tier"), a.get("valuation_tier") or "")
|
||||
ratio = a.get("price_ratio")
|
||||
budget = " · 예산 내 ✅" if a.get("budget_ok") else ""
|
||||
if ratio is not None:
|
||||
judge = (f"{tier} (호가율 {int(ratio * 100)}% · 실거래 median {_manwon(a.get('market_median'))}, "
|
||||
f"{a.get('sample_size', 0)}건){budget}")
|
||||
else:
|
||||
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
|
||||
flags = a.get("regulation_flags") or []
|
||||
warn = "⚠️ " + (" · ".join(flags) if flags else "비토허") + " · 등기부 선순위 수동 확인 필수"
|
||||
|
||||
area = f"전용 {a.get('area_exclusive')}㎡" if a.get("area_exclusive") else ""
|
||||
return (
|
||||
f"🏠 [{a.get('deal_type') or ''}] {a.get('complex_name') or ''} · {price} · {area} · {a.get('floor') or ''}\n"
|
||||
f"{judge}\n"
|
||||
f"📍 {a.get('dong') or ''} · {warn}\n"
|
||||
f"🔗 {a.get('url') or ''}"
|
||||
)
|
||||
|
||||
|
||||
async def send_listing_alerts(listings: List[Dict[str, Any]]) -> dict:
|
||||
"""매물마다 본인+아내 chat_id 각각으로 send_raw. 1건 이상 성공하면 delivered→sent_ids에 id 수집.
|
||||
실패해도 나머지 계속 진행(per-send try/except)."""
|
||||
sent = 0
|
||||
sent_ids: List[Any] = []
|
||||
all_ok = True
|
||||
chat_ids = [c for c in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if c]
|
||||
for a in listings:
|
||||
text = format_listing_alert(a)
|
||||
delivered = False
|
||||
for cid in chat_ids:
|
||||
try:
|
||||
r = await send_raw(text, chat_id=cid)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_realestate_listing] send failed (chat_id={cid}): {e}")
|
||||
all_ok = False
|
||||
continue
|
||||
if r.get("ok"):
|
||||
sent += 1
|
||||
delivered = True
|
||||
else:
|
||||
all_ok = False
|
||||
if delivered and a.get("id") is not None:
|
||||
sent_ids.append(a["id"])
|
||||
return {"sent": sent, "sent_ids": sent_ids, "ok": all_ok}
|
||||
78
agent-office/tests/test_realestate_listing_notify.py
Normal file
78
agent-office/tests/test_realestate_listing_notify.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""매물 알림 텔레그램 렌더+전송 테스트. 기존 test_trade_alert_notify.py 패턴(async mock + chat_id patch) 준수."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_listing_rent_and_sale():
|
||||
from app.notifiers.telegram_realestate_listing import format_listing_alert
|
||||
|
||||
rent = format_listing_alert({"id": 1, "category": "임차", "deal_type": "전세",
|
||||
"complex_name": "OO", "deposit": 29000, "area_exclusive": 42.0, "floor": "5/15",
|
||||
"safety_tier": "안전", "jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
|
||||
"dong": "신대방동", "url": "http://x", "regulation_flags": []})
|
||||
assert "전세" in rent and "안전" in rent and "OO" in rent and "68" in rent
|
||||
|
||||
sale = format_listing_alert({"id": 2, "category": "매매", "deal_type": "매매",
|
||||
"complex_name": "PP", "sale_price": 89000, "area_exclusive": 59.0, "floor": "12/20",
|
||||
"valuation_tier": "시세", "price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
|
||||
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []})
|
||||
assert "매매" in sale and "시세" in sale
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_listing_pending_sample_and_regulation_flags():
|
||||
"""표본<3(보류)·토허 등 regulation_flags 경고 라인이 포함되는지."""
|
||||
from app.notifiers.telegram_realestate_listing import format_listing_alert
|
||||
|
||||
pending = format_listing_alert({"id": 3, "category": "임차", "deal_type": "전세",
|
||||
"complex_name": "QQ", "deposit": 28000, "area_exclusive": 40.0, "floor": "3/10",
|
||||
"safety_tier": "보류", "jeonse_ratio": None, "market_median": None, "sample_size": 1,
|
||||
"dong": "봉천동", "url": "http://z", "regulation_flags": []})
|
||||
assert "보류" in pending
|
||||
|
||||
toheo = format_listing_alert({"id": 4, "category": "매매", "deal_type": "매매",
|
||||
"complex_name": "RR", "sale_price": 150000, "area_exclusive": 84.0, "floor": "7/15",
|
||||
"valuation_tier": "고가", "price_ratio": 1.1, "market_median": 136000, "sample_size": 5,
|
||||
"dong": "대치동", "url": "http://w", "budget_ok": 0, "regulation_flags": ["토허"]})
|
||||
assert "토허" in toheo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_listing_alerts_returns_sent_ids():
|
||||
from app.notifiers import telegram_realestate_listing as t
|
||||
items = [{"id": 5, "category": "임차", "deal_type": "전세", "complex_name": "OO",
|
||||
"deposit": 29000, "area_exclusive": 42.0, "safety_tier": "안전",
|
||||
"jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
|
||||
"dong": "신대방동", "url": "http://x", "regulation_flags": []}]
|
||||
with patch("app.notifiers.telegram_realestate_listing.send_raw",
|
||||
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
|
||||
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
|
||||
res = await t.send_listing_alerts(items)
|
||||
assert res["sent_ids"] == [5] and res["ok"] is True
|
||||
assert res["sent"] == 2 # 너+아내 둘 다
|
||||
chat_ids = {c.kwargs.get("chat_id") for c in m.await_args_list}
|
||||
assert chat_ids == {"U", "W"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_listing_alerts_partial_failure_not_marked_sent():
|
||||
"""한쪽 chat_id 전송이 예외를 던져도 나머지는 계속 발송되고, 최소 1건 성공이면 sent_ids에 포함."""
|
||||
from app.notifiers import telegram_realestate_listing as t
|
||||
items = [{"id": 6, "category": "매매", "deal_type": "매매", "complex_name": "PP",
|
||||
"sale_price": 89000, "area_exclusive": 59.0, "valuation_tier": "시세",
|
||||
"price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
|
||||
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []}]
|
||||
|
||||
async def _flaky(text, chat_id=None, **kw):
|
||||
if chat_id == "U":
|
||||
raise RuntimeError("network error")
|
||||
return {"ok": True}
|
||||
|
||||
with patch("app.notifiers.telegram_realestate_listing.send_raw", side_effect=_flaky), \
|
||||
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
|
||||
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
|
||||
res = await t.send_listing_alerts(items)
|
||||
assert res["sent_ids"] == [6]
|
||||
assert res["ok"] is False # 일부 실패했으므로 all_ok=False
|
||||
Reference in New Issue
Block a user