diff --git a/agent-office/app/service_proxy.py b/agent-office/app/service_proxy.py index cace022..7c25d86 100644 --- a/agent-office/app/service_proxy.py +++ b/agent-office/app/service_proxy.py @@ -111,6 +111,29 @@ async def stock_holdings_brief() -> Dict[str, Any]: return resp.json() +# --- stock watchlist (실시간 매매 알람) --- + +async def watchlist_add(ticker: str) -> Dict[str, Any]: + """stock의 관심종목 추가 (POST, 이미 존재하면 멱등하게 갱신).""" + resp = await _client.post(f"{STOCK_URL}/api/stock/watchlist", json={"ticker": ticker}) + resp.raise_for_status() + return resp.json() + + +async def watchlist_remove(ticker: str) -> Dict[str, Any]: + """stock의 관심종목 삭제.""" + resp = await _client.delete(f"{STOCK_URL}/api/stock/watchlist/{ticker}") + resp.raise_for_status() + return resp.json() + + +async def watchlist_list() -> Dict[str, Any]: + """stock의 관심종목 목록 조회 → {"watchlist": [...]}.""" + resp = await _client.get(f"{STOCK_URL}/api/stock/watchlist") + resp.raise_for_status() + return resp.json() + + async def generate_music(payload: dict) -> Dict[str, Any]: resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload) resp.raise_for_status() diff --git a/agent-office/app/telegram/webhook.py b/agent-office/app/telegram/webhook.py index d15466d..6bf11ba 100644 --- a/agent-office/app/telegram/webhook.py +++ b/agent-office/app/telegram/webhook.py @@ -1,6 +1,7 @@ """텔레그램 Webhook 이벤트 처리.""" from typing import Optional +from .. import service_proxy from ..db import get_telegram_callback, mark_telegram_responded from .client import _enabled, api_call @@ -23,12 +24,43 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]: if message: chat = message.get("chat", {}) print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True) + if message and message.get("text"): + if await handle_watch_command(message): + return None if message and message.get("text") and agent_dispatcher is not None: return await _handle_message(message, agent_dispatcher) return None +async def handle_watch_command(message: dict) -> bool: + """/watch /unwatch /watchlist 명령을 처리해 stock watchlist API로 프록시. + + 처리했으면(응답 전송 포함) True, 매칭되지 않는 텍스트면 False.""" + text = (message.get("text") or "").strip() + chat_id = message.get("chat", {}).get("id") + parts = text.split() + cmd = parts[0].lower() if parts else "" + + if cmd == "/watch" and len(parts) >= 2: + await service_proxy.watchlist_add(parts[1]) + reply = f"관심종목 추가: {parts[1]}" + elif cmd == "/unwatch" and len(parts) >= 2: + await service_proxy.watchlist_remove(parts[1]) + reply = f"관심종목 삭제: {parts[1]}" + elif cmd == "/watchlist": + res = await service_proxy.watchlist_list() + items = res.get("watchlist", []) + reply = "관심종목:\n" + ( + "\n".join(f"- {w.get('name') or ''} ({w['ticker']})" for w in items) or "(없음)" + ) + else: + return False + + await api_call("sendMessage", {"chat_id": chat_id, "text": reply}) + return True + + async def _handle_callback(callback_query: dict) -> Optional[dict]: """승인/거절 및 realestate 북마크 콜백 처리.""" callback_id = callback_query.get("data", "") diff --git a/agent-office/tests/test_watch_commands.py b/agent-office/tests/test_watch_commands.py new file mode 100644 index 0000000..7f0db6a --- /dev/null +++ b/agent-office/tests/test_watch_commands.py @@ -0,0 +1,93 @@ +import os +import sys +import tempfile + +_fd, _TMP = tempfile.mkstemp(suffix=".db") +os.close(_fd) +os.unlink(_TMP) +os.environ["AGENT_OFFICE_DB_PATH"] = _TMP + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from unittest.mock import AsyncMock, patch + + +@pytest.fixture(autouse=True) +def _init_db(monkeypatch): + import gc + gc.collect() + # config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시 + # db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로 + # 강제해 영속 테이블의 테스트 간 누수를 결정적으로 차단. + import app.db as _db + monkeypatch.setattr(_db, "DB_PATH", _TMP) + for suffix in ("", "-wal", "-shm"): + p = _TMP + suffix + if os.path.exists(p): + os.remove(p) + _db.init_db() + yield + gc.collect() + + +@pytest.mark.asyncio +async def test_watch_command_calls_add(): + from app.telegram import webhook + msg = {"chat": {"id": 1}, "text": "/watch 005930"} + with patch("app.telegram.webhook.service_proxy.watchlist_add", + new=AsyncMock(return_value={"ok": True})) as m, \ + patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})): + handled = await webhook.handle_watch_command(msg) + assert handled is True + m.assert_awaited_once_with("005930") + + +@pytest.mark.asyncio +async def test_non_watch_text_ignored(): + from app.telegram import webhook + msg = {"chat": {"id": 1}, "text": "안녕"} + assert await webhook.handle_watch_command(msg) is False + + +@pytest.mark.asyncio +async def test_unwatch_command_calls_remove(): + from app.telegram import webhook + msg = {"chat": {"id": 1}, "text": "/unwatch 005930"} + with patch("app.telegram.webhook.service_proxy.watchlist_remove", + new=AsyncMock(return_value={"ok": True})) as m, \ + patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent: + handled = await webhook.handle_watch_command(msg) + assert handled is True + m.assert_awaited_once_with("005930") + sent.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_watchlist_command_calls_list_and_formats_items(): + from app.telegram import webhook + msg = {"chat": {"id": 1}, "text": "/watchlist"} + items = {"watchlist": [{"ticker": "005930", "name": "삼성전자"}]} + with patch("app.telegram.webhook.service_proxy.watchlist_list", + new=AsyncMock(return_value=items)) as m, \ + patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent: + handled = await webhook.handle_watch_command(msg) + assert handled is True + m.assert_awaited_once_with() + text = sent.await_args.args[1]["text"] + assert "005930" in text and "삼성전자" in text + + +@pytest.mark.asyncio +async def test_watch_command_reaches_handle_webhook_before_slash_dispatch(): + """handle_webhook이 /watch 를 agent_dispatcher 호출 전에 가로채야 한다.""" + from app.telegram import webhook + data = {"message": {"chat": {"id": 1}, "text": "/watch 005930"}} + dispatcher = AsyncMock(side_effect=AssertionError("agent_dispatcher가 호출되면 안 됨")) + with patch("app.telegram.webhook.service_proxy.watchlist_add", + new=AsyncMock(return_value={"ok": True})) as m, \ + patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})): + result = await webhook.handle_webhook(data, agent_dispatcher=dispatcher) + assert result is None + m.assert_awaited_once_with("005930") + dispatcher.assert_not_awaited()