From 32e021cfc78eb0fb11531d2ae6ae4b441aa28d73 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 09:01:38 +0900 Subject: [PATCH] feat(agent-office): drop daily realestate cron + bookmark callback routing - scheduler.py: remove _run_realestate_schedule() and its 09:15 cron job - service_proxy.py: add realestate_bookmark_toggle() helper (PATCH bookmark endpoint) - webhook.py: add _handle_realestate_bookmark() dispatcher before DB-lookup path; realestate_bookmark_{id} callbacks are handled inline without a DB entry - tests/test_realestate_callback.py: 4 new unit tests covering happy path, invalid id, proxy error, and regression that approve/reject still uses DB path Co-Authored-By: Claude Sonnet 4.6 --- agent-office/app/scheduler.py | 6 - agent-office/app/service_proxy.py | 10 ++ agent-office/app/telegram/webhook.py | 39 +++++- .../tests/test_realestate_callback.py | 128 ++++++++++++++++++ 4 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 agent-office/tests/test_realestate_callback.py diff --git a/agent-office/app/scheduler.py b/agent-office/app/scheduler.py index 7eeacf6..4b619ca 100644 --- a/agent-office/app/scheduler.py +++ b/agent-office/app/scheduler.py @@ -14,11 +14,6 @@ async def _run_stock_schedule(): if agent: await agent.on_schedule() -async def _run_realestate_schedule(): - agent = AGENT_REGISTRY.get("realestate") - if agent: - await agent.on_schedule() - async def _run_blog_schedule(): agent = AGENT_REGISTRY.get("blog") if agent: @@ -31,7 +26,6 @@ async def _run_lotto_schedule(): def init_scheduler(): scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news") - scheduler.add_job(_run_realestate_schedule, "cron", hour=9, minute=15, id="realestate_report") scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline") scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate") scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check") diff --git a/agent-office/app/service_proxy.py b/agent-office/app/service_proxy.py index 8e63382..51a5f7a 100644 --- a/agent-office/app/service_proxy.py +++ b/agent-office/app/service_proxy.py @@ -133,6 +133,16 @@ async def realestate_mark_read(match_id: int) -> Dict[str, Any]: return resp.json() +async def realestate_bookmark_toggle(announcement_id: int) -> Dict[str, Any]: + """realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.patch( + f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark" + ) + resp.raise_for_status() + return resp.json() + + # --- lotto-backend --- async def lotto_candidates(n: int = 20) -> Dict[str, Any]: diff --git a/agent-office/app/telegram/webhook.py b/agent-office/app/telegram/webhook.py index c174a71..6883b52 100644 --- a/agent-office/app/telegram/webhook.py +++ b/agent-office/app/telegram/webhook.py @@ -30,8 +30,13 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]: async def _handle_callback(callback_query: dict) -> Optional[dict]: - """기존 승인/거절 콜백 처리 로직.""" + """승인/거절 및 realestate 북마크 콜백 처리.""" callback_id = callback_query.get("data", "") + + # realestate 북마크 토글 콜백 — DB 조회 없이 직접 처리 + if callback_id.startswith("realestate_bookmark_"): + return await _handle_realestate_bookmark(callback_query, callback_id) + cb = get_telegram_callback(callback_id) if not cb: return None @@ -60,6 +65,38 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]: } +async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) -> dict: + """realestate_bookmark_{announcement_id} 콜백 처리.""" + from .. import service_proxy + from .messaging import send_raw + + # answerCallbackQuery 먼저 — 텔레그램 로딩 스피너 해제 + await api_call( + "answerCallbackQuery", + {"callback_query_id": callback_query["id"], "text": "처리 중..."}, + ) + + try: + ann_id = int(callback_id.removeprefix("realestate_bookmark_")) + except ValueError: + await send_raw("⚠️ 잘못된 북마크 콜백 데이터") + return {"ok": False, "error": "invalid_callback_data"} + + try: + result = await service_proxy.realestate_bookmark_toggle(ann_id) + bookmarked = result.get("bookmarked", None) + if bookmarked is True: + await send_raw(f"🔖 북마크 추가 완료 (#{ann_id})") + elif bookmarked is False: + await send_raw(f"🔖 북마크 해제 완료 (#{ann_id})") + else: + await send_raw(f"🔖 북마크 토글 완료 (#{ann_id})") + return {"ok": True, "announcement_id": ann_id} + except Exception as e: + await send_raw(f"⚠️ 북마크 처리 실패: {e}") + return {"ok": False, "error": str(e)} + + async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]: """슬래시 명령 메시지 처리.""" from .router import parse_command, resolve_agent_command, HELP_TEXT diff --git a/agent-office/tests/test_realestate_callback.py b/agent-office/tests/test_realestate_callback.py new file mode 100644 index 0000000..b7b3364 --- /dev/null +++ b/agent-office/tests/test_realestate_callback.py @@ -0,0 +1,128 @@ +import os +import sys +import tempfile +import gc +from unittest.mock import AsyncMock, patch + +_TMP = tempfile.mktemp(suffix=".db") +os.environ["AGENT_OFFICE_DB_PATH"] = _TMP +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import asyncio +import pytest + + +@pytest.fixture(autouse=True) +def _init_db(): + gc.collect() + if os.path.exists(_TMP): + try: + os.remove(_TMP) + except PermissionError: + pass + from app.db import init_db + init_db() + yield + + +def test_callback_realestate_bookmark_calls_proxy(): + """callback_data 'realestate_bookmark_42' 가 service_proxy.realestate_bookmark_toggle(42) 를 호출.""" + from app import service_proxy + from app.telegram import webhook + + fake_toggle = AsyncMock(return_value={"bookmarked": True}) + fake_send = AsyncMock(return_value={"ok": True}) + fake_api_call = AsyncMock(return_value={"ok": True}) + + update = { + "callback_query": { + "id": "cb1", + "from": {"id": 1}, + "data": "realestate_bookmark_42", + } + } + + with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \ + patch("app.telegram.messaging.send_raw", fake_send), \ + patch("app.telegram.webhook.api_call", fake_api_call): + result = asyncio.run(webhook.handle_webhook(update)) + + fake_toggle.assert_awaited_once_with(42) + assert result == {"ok": True, "announcement_id": 42} + + +def test_callback_realestate_bookmark_invalid_id(): + """callback_data 'realestate_bookmark_abc' 는 ValueError를 처리하고 에러 응답 반환.""" + from app import service_proxy + from app.telegram import webhook + + fake_toggle = AsyncMock(return_value={"bookmarked": True}) + fake_send = AsyncMock(return_value={"ok": True}) + fake_api_call = AsyncMock(return_value={"ok": True}) + + update = { + "callback_query": { + "id": "cb2", + "from": {"id": 1}, + "data": "realestate_bookmark_abc", + } + } + + with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \ + patch("app.telegram.messaging.send_raw", fake_send), \ + patch("app.telegram.webhook.api_call", fake_api_call): + result = asyncio.run(webhook.handle_webhook(update)) + + fake_toggle.assert_not_awaited() + assert result is not None + assert result.get("ok") is False + assert result.get("error") == "invalid_callback_data" + + +def test_callback_realestate_bookmark_proxy_error(): + """service_proxy 가 예외를 던질 때 에러 응답 반환.""" + from app import service_proxy + from app.telegram import webhook + + fake_toggle = AsyncMock(side_effect=Exception("connection refused")) + fake_send = AsyncMock(return_value={"ok": True}) + fake_api_call = AsyncMock(return_value={"ok": True}) + + update = { + "callback_query": { + "id": "cb3", + "from": {"id": 1}, + "data": "realestate_bookmark_99", + } + } + + with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \ + patch("app.telegram.messaging.send_raw", fake_send), \ + patch("app.telegram.webhook.api_call", fake_api_call): + result = asyncio.run(webhook.handle_webhook(update)) + + fake_toggle.assert_awaited_once_with(99) + assert result is not None + assert result.get("ok") is False + assert "connection refused" in result.get("error", "") + + +def test_non_realestate_callback_uses_db_path(): + """approve_*/reject_* 콜백은 기존 DB 조회 경로를 사용 (realestate 분기를 타지 않음).""" + from app.telegram import webhook + + fake_api_call = AsyncMock(return_value={"ok": True}) + + update = { + "callback_query": { + "id": "cb4", + "from": {"id": 1}, + "data": "approve_abcd1234", + } + } + + # DB에 등록되지 않은 콜백이므로 None 반환 — 기존 로직 진입 확인 + with patch("app.telegram.webhook.api_call", fake_api_call): + result = asyncio.run(webhook.handle_webhook(update)) + + assert result is None # DB에 없으면 None 반환 (기존 동작 유지)