From 6eb24090ed190f34e41f2ba64a33d365d744c673 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 00:47:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20InstaAgent=20=E2=80=94=20?= =?UTF-8?q?daily=20extract=20+=20keyword=20push=20+=20media=20group=20rend?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-office/app/agents/insta.py | 162 +++++++++++++++++++++++++ agent-office/tests/test_insta_agent.py | 85 +++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 agent-office/app/agents/insta.py create mode 100644 agent-office/tests/test_insta_agent.py diff --git a/agent-office/app/agents/insta.py b/agent-office/app/agents/insta.py new file mode 100644 index 0000000..2750879 --- /dev/null +++ b/agent-office/app/agents/insta.py @@ -0,0 +1,162 @@ +"""인스타 카드 에이전트 — 매일 09:30 뉴스 수집·키워드 추출 → 텔레그램 후보 푸시. +사용자가 키워드 버튼을 누르면 카드 슬레이트 생성 + 10장 미디어 그룹 발송.""" + +import asyncio +import json +import logging +from typing import Any, Dict, List, Optional + +import httpx + +from .base import BaseAgent +from ..db import ( + create_task, update_task_status, add_log, get_agent_config, +) +from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +from .. import service_proxy +from ..telegram import messaging + +logger = logging.getLogger(__name__) + + +async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]: + """텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts. + 각 항목에는 임시 키 '_bytes'로 PNG 바이트가 담겨 있어 attach:// 형식으로 multipart 업로드.""" + if not TELEGRAM_BOT_TOKEN: + return {"ok": False, "reason": "TELEGRAM_BOT_TOKEN missing"} + url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup" + files: Dict[str, tuple] = {} + for i, m in enumerate(media): + attach_key = f"photo{i+1}" + files[attach_key] = (f"{i+1}.png", m["_bytes"], "image/png") + m["media"] = f"attach://{attach_key}" + m.pop("_bytes", None) + if caption and media: + media[0]["caption"] = caption[:1024] + payload = {"chat_id": TELEGRAM_CHAT_ID, "media": json.dumps(media, ensure_ascii=False)} + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, data=payload, files=files) + return resp.json() + + +class InstaAgent(BaseAgent): + agent_id = "insta" + display_name = "인스타 큐레이터" + + async def on_schedule(self) -> None: + """09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시. + custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성.""" + if self.state not in ("idle", "break"): + return + config = get_agent_config(self.agent_id) or {} + custom = config.get("custom_config", {}) or {} + auto_select = bool(custom.get("auto_select", False)) + + task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select}, + requires_approval=False) + await self.transition("working", "뉴스 수집·키워드 추출", task_id) + try: + await self._run_collect_and_extract() + kws = await service_proxy.insta_list_keywords(used=False) + if auto_select: + await self._auto_render(kws) + else: + await self._push_keyword_candidates(kws) + update_task_status(task_id, "succeeded", {"keywords": len(kws)}) + await self.transition("idle", "후보 푸시 완료") + except Exception as e: + add_log(self.agent_id, f"insta daily failed: {e}", "error", task_id) + update_task_status(task_id, "failed", {"error": str(e)}) + await self.transition("idle", f"오류: {e}") + + async def _run_collect_and_extract(self) -> None: + col = await service_proxy.insta_collect() + await self._wait_task(col["task_id"], step="collect", timeout_sec=300) + ext = await service_proxy.insta_extract() + await self._wait_task(ext["task_id"], step="extract", timeout_sec=300) + + async def _wait_task(self, task_id: str, step: str, timeout_sec: int = 300) -> Dict[str, Any]: + attempts = max(1, timeout_sec // 5) + for _ in range(attempts): + await asyncio.sleep(5) + st = await service_proxy.insta_task_status(task_id) + if st["status"] == "succeeded": + return st + if st["status"] == "failed": + raise RuntimeError(f"{step} failed: {st.get('error')}") + raise TimeoutError(f"{step} timeout {timeout_sec}s") + + async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None: + by_cat: Dict[str, List[Dict[str, Any]]] = {} + for k in keywords: + by_cat.setdefault(k["category"], []).append(k) + if not by_cat: + await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.") + return + rows: List[List[Dict[str, Any]]] = [] + text_lines = ["📰 [인스타 큐레이터] 오늘의 키워드 후보"] + for cat, items in by_cat.items(): + text_lines.append(f"\n{cat}") + for k in items[:5]: + text_lines.append(f" · {k['keyword']} (score {k['score']:.2f})") + rows.append([{ + "text": f"🎴 {k['keyword']}", + "callback_data": f"render_{k['id']}", + }]) + await messaging.send_raw("\n".join(text_lines), reply_markup={"inline_keyboard": rows}) + + async def _auto_render(self, keywords: List[Dict[str, Any]]) -> None: + by_cat: Dict[str, Dict[str, Any]] = {} + for k in keywords: + cat = k["category"] + if cat not in by_cat or k["score"] > by_cat[cat]["score"]: + by_cat[cat] = k + for kw in by_cat.values(): + await self._render_and_push(kw["id"]) + + async def _render_and_push(self, keyword_id: int) -> None: + kw = await service_proxy.insta_get_keyword(keyword_id) + if not kw: + await messaging.send_raw(f"⚠️ 키워드 {keyword_id} 없음") + return + await messaging.send_raw(f"🎨 카드 생성 중: {kw['keyword']}") + created = await service_proxy.insta_create_slate( + keyword=kw["keyword"], category=kw["category"], keyword_id=kw["id"], + ) + st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600) + slate_id = st["result_id"] + slate = await service_proxy.insta_get_slate(slate_id) + media = [] + for a in slate["assets"][:10]: + data = await service_proxy.insta_get_asset_bytes(slate_id, a["page_index"]) + media.append({"type": "photo", "_bytes": data}) + caption = slate.get("suggested_caption", "") + hashtags = " ".join(slate.get("hashtags", []) or []) + full_caption = f"{caption}\n\n{hashtags}".strip() + await _send_media_group(media, caption=full_caption) + + async def on_command(self, command: str, params: dict) -> dict: + if command == "extract": + await self._run_collect_and_extract() + kws = await service_proxy.insta_list_keywords(used=False) + await self._push_keyword_candidates(kws) + return {"ok": True, "count": len(kws)} + if command == "render": + kid = int(params.get("keyword_id") or 0) + if not kid: + return {"ok": False, "message": "keyword_id 필수"} + await self._render_and_push(kid) + return {"ok": True} + return {"ok": False, "message": f"Unknown command: {command}"} + + async def on_callback(self, action: str, params: dict) -> dict: + if action == "render": + kid = int(params.get("keyword_id") or 0) + if not kid: + return {"ok": False} + await self._render_and_push(kid) + return {"ok": True} + return {"ok": False} + + async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: + return diff --git a/agent-office/tests/test_insta_agent.py b/agent-office/tests/test_insta_agent.py new file mode 100644 index 0000000..13c0c96 --- /dev/null +++ b/agent-office/tests/test_insta_agent.py @@ -0,0 +1,85 @@ +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__)))) + +from unittest.mock import patch, AsyncMock, MagicMock + +import pytest + +from app.agents.insta import InstaAgent + + +@pytest.fixture(autouse=True) +def _init_db(): + import gc + gc.collect() + if os.path.exists(_TMP): + os.remove(_TMP) + from app.db import init_db + init_db() + yield + gc.collect() + + +@pytest.mark.asyncio +async def test_on_command_extract_dispatches(monkeypatch): + agent = InstaAgent() + fake_collect = AsyncMock(return_value={"task_id": "tcollect"}) + fake_extract = AsyncMock(return_value={"task_id": "textract"}) + fake_status = AsyncMock(side_effect=[ + {"status": "succeeded", "result_id": 0}, + {"status": "succeeded", "result_id": 0}, + ]) + fake_keywords = AsyncMock(return_value=[ + {"id": 1, "keyword": "K1", "category": "economy", "score": 0.9}, + {"id": 2, "keyword": "K2", "category": "psychology", "score": 0.8}, + ]) + + monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords) + monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True})) + + result = await agent.on_command("extract", {}) + assert result["ok"] is True + fake_collect.assert_awaited() + fake_extract.assert_awaited() + + +@pytest.mark.asyncio +async def test_on_callback_render_kicks_pipeline(monkeypatch): + agent = InstaAgent() + fake_kw = AsyncMock(return_value={"id": 7, "keyword": "테스트", "category": "economy"}) + fake_create = AsyncMock(return_value={"task_id": "tslate"}) + fake_status = AsyncMock(side_effect=[ + {"status": "processing"}, + {"status": "succeeded", "result_id": 42}, + ]) + fake_slate = AsyncMock(return_value={ + "id": 42, "status": "rendered", + "suggested_caption": "캡션", "hashtags": ["#a", "#b"], + "assets": [{"page_index": i, "file_path": f"/x/{i}.png"} for i in range(1, 11)], + }) + fake_bytes = AsyncMock(side_effect=[b"PNG"] * 10) + fake_send_media = AsyncMock(return_value={"ok": True}) + + monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_keyword", fake_kw) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_create_slate", fake_create) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", fake_slate) + monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", fake_bytes) + monkeypatch.setattr("app.agents.insta._send_media_group", fake_send_media) + monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True})) + + out = await agent.on_callback("render", {"keyword_id": 7}) + assert out["ok"] is True + fake_create.assert_awaited() + fake_send_media.assert_awaited()