feat(agent-office): /watch /unwatch /watchlist 봇 명령
This commit is contained in:
@@ -111,6 +111,29 @@ async def stock_holdings_brief() -> Dict[str, Any]:
|
|||||||
return resp.json()
|
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]:
|
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""텔레그램 Webhook 이벤트 처리."""
|
"""텔레그램 Webhook 이벤트 처리."""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from .. import service_proxy
|
||||||
from ..db import get_telegram_callback, mark_telegram_responded
|
from ..db import get_telegram_callback, mark_telegram_responded
|
||||||
from .client import _enabled, api_call
|
from .client import _enabled, api_call
|
||||||
|
|
||||||
@@ -23,12 +24,43 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
|||||||
if message:
|
if message:
|
||||||
chat = message.get("chat", {})
|
chat = message.get("chat", {})
|
||||||
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
|
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:
|
if message and message.get("text") and agent_dispatcher is not None:
|
||||||
return await _handle_message(message, agent_dispatcher)
|
return await _handle_message(message, agent_dispatcher)
|
||||||
|
|
||||||
return None
|
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]:
|
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||||
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
||||||
callback_id = callback_query.get("data", "")
|
callback_id = callback_query.get("data", "")
|
||||||
|
|||||||
93
agent-office/tests/test_watch_commands.py
Normal file
93
agent-office/tests/test_watch_commands.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user