diff --git a/docs/tasks/TASK_PHASE_PRESENCE_LAYER_v1.md b/docs/tasks/TASK_PHASE_PRESENCE_LAYER_v1.md new file mode 100644 index 00000000..6a88a2b1 --- /dev/null +++ b/docs/tasks/TASK_PHASE_PRESENCE_LAYER_v1.md @@ -0,0 +1,228 @@ +# TASK_PHASE_PRESENCE_LAYER_v1 + +Version: 1.0 +Status: Ready +Priority: Critical (Agent Presence / City Presence / Chat Online State) + +--- + +# 1. МЕТА ТА ОПИС + +Впровадити **повний Presence Layer** у DAARION.city: + +- online/offline/away для кожного агента; +- Matrix → Gateway → City Service → Frontend; +- інтеграція у всі ключові сторінки: + `/agents`, `/agents/:id`, `/nodes/:nodeId`, `/microdao/:slug`, `/city`. + +Presence — це системний фундамент для: +- живих агентів у місті, +- реального часу в чаті, +- індикаторів активності кімнат, +- майбутнього AI presence (LLM/Автономні агенти), +- системної безпеки. + +--- + +# 2. ПРИНЦИП РОБОТИ (ІНВАРІАНТ) + +Presence = стан Matrix user. + +Кожен агент має: +- `agent.matrix_user_id` (наприклад, `@daarwizz:matrix.daarion.city`) +- presence береться з Matrix Homeserver API +- gateway перетворює у нормалізовану форму +- city-service кешує стан +- frontend показує живий статус + +--- + +# 3. МОДУЛЬ 1 — MATRIX GATEWAY (BACKEND) + +## 3.1. Додати endpoint + +`GET /internal/matrix/presence/{matrix_user_id}` + +### Вихід: + +```json +{ + "user_id": "@dario:matrix.daarion.city", + "presence": "online" | "offline" | "unavailable", + "last_active_ago_ms": 12345 +} +``` + +### Логіка: + +- Використати Matrix API + `/_matrix/client/v3/presence/{userId}/status` +- Обробити помилки: + - no such user → `"offline"` + - rate limited → повторити 1 раз +- Нормалізувати: + - online → "online" + - unavailable → "away" + - offline → "offline" + +## 3.2. Модуль Presence Poller (опціонально, але бажано) + +- Кожні 30–60 секунд запитувати статуси публічних агентів. +- Зберігати кеш у пам'яті gateway. +- Зменшує навантаження на Matrix. + +--- + +# 4. МОДУЛЬ 2 — CITY-SERVICE (API) + +## 4.1. Новий endpoint: + +`GET /api/v1/agents/{agent_id}/presence` + +### Вихід: + +```json +{ + "agent_id": "uuid", + "presence": "online" | "offline" | "away", + "matrix_user_id": "@agent_daarwizz:matrix.daarion.city", + "last_active_ago_ms": +} +``` + +### Логіка: + +- city-service знаходить `agent.matrix_user_id` +- робить запит до gateway: + `/internal/matrix/presence/{mxid}` +- кешує відповідь на 15–30 секунд (optional) +- повертає frontend + +--- + +# 5. МОДУЛЬ 3 — FRONTEND (Next.js) + +## 5.1. Новий API-клієнт + +`lib/api/presence.ts`: + +- `getAgentPresence(agentId)` +- повертає normalized presence + +## 5.2. Компонент PresenceDot + +`` + +- online → зелений +- away → жовтий +- offline → сірий + +## 5.3. Інтеграції + +### `/agents` + +У таблиці списку агентів: + +- додати `` зліва від аватарки. + +### `/agents/:id` + +Під аватаркою агента: + +- "Статус: online/offline/away" + +### `/nodes/:nodeId` + +Для Guardian/Steward: + +- показати presence + +### `/microdao/:slug` + +Для Orchestrator: + +- показати presence + +### `/city` + +У City Rooms списку: + +- показати presence адміністраторів кімнат (DARIO/DARIA) + +--- + +# 6. МОДУЛЬ 4 — CHAT WIDGET інтеграція + +### 6.1. У Chat Widget: + +- якщо presence = offline → показати "Агент офлайн" +- якщо presence = away → "Агент може відповісти із затримкою" +- якщо presence = online → нормальна робота + +### 6.2. Додати auto-refresh presence кожні 20–30 секунд + +--- + +# 7. SMOKE ТЕСТИ + +Після завершення: + +1. `/agents` + — для кожного публічного агента видно presence. + +2. `/agents/daarwizz` + — показує реальний статус DAARWIZZ. + +3. `/nodes/node-1-hetzner-gex44` + — guardian/steward мають presence-індикатор. + +4. `/microdao/daarion` + — orchestrator DAARWIZZ показує presence. + +5. Chat Widget + — якщо агент offline → видно попередження + — якщо online → можливо писати + +6. Gateway + — endpoint `/internal/matrix/presence/{id}` повертає реальні дані. + +--- + +# 8. ФІНАЛЬНИЙ АРТЕФАКТ + +Cursor після виконання створює: + +`docs/debug/presence_layer_report_.md` + +Зміст: + +- список agent → presence +- список кімнат → адміністратори → presence +- приклади API-відповідей gateway і city-service +- скріншоти або описи UI-тестів + +--- + +# 9. PROMPT ДЛЯ CURSOR + +``` +Виконай TASK_PHASE_PRESENCE_LAYER_v1.md. + +Модулі: +1) matrix-gateway — додати presence API +2) city-service — presence endpoint +3) frontend — presence інтеграція до agent/node/microdao/city +4) chat widget — presence-механіка + +Після завершення створи файл: +docs/debug/presence_layer_report_.md + +Орієнтуйся на foundation-документи у docs/foundation/. +``` + +--- + +**Target Date**: Immediate +**Priority**: Critical +**Dependencies**: Matrix Gateway running, Synapse accessible + diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index d84e54dd..67ea79ae 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -1736,6 +1736,61 @@ async def get_agents_presence(): raise HTTPException(status_code=500, detail="Failed to get agents presence") +@api_router.get("/agents/{agent_id}/presence") +async def get_single_agent_presence(agent_id: str): + """ + Отримати presence статус одного агента. + Використовує Matrix Gateway для отримання реального статусу. + """ + try: + # Get agent from DB + agent = await repo_city.get_agent_by_id(agent_id) + if not agent: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + + # Get Matrix user ID for agent (or generate it) + # Pattern: @agent_{slug}:daarion.space + agent_slug = agent.get("public_slug") or agent_id.replace("ag_", "").replace("-", "_") + matrix_user_id = f"@agent_{agent_slug}:daarion.space" + + # Get presence from Matrix Gateway + presence_data = await get_matrix_presence_for_user(matrix_user_id) + + return { + "agent_id": agent_id, + "display_name": agent.get("display_name"), + "matrix_user_id": matrix_user_id, + "presence": presence_data.get("presence", "offline"), + "last_active_ago_ms": presence_data.get("last_active_ago_ms"), + "status_msg": presence_data.get("status_msg") + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get agent presence for {agent_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to get agent presence") + + +async def get_matrix_presence_for_user(matrix_user_id: str) -> dict: + """ + Get Matrix presence for a single user via Matrix Gateway. + """ + try: + gateway_url = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025") + async with httpx.AsyncClient(timeout=5.0) as client: + # URL encode the matrix user ID + encoded_mxid = matrix_user_id.replace("@", "%40").replace(":", "%3A") + response = await client.get(f"{gateway_url}/internal/matrix/presence/{encoded_mxid}") + if response.status_code == 200: + return response.json() + else: + logger.warning(f"Matrix gateway presence returned {response.status_code}") + return {"presence": "offline"} + except Exception as e: + logger.warning(f"Failed to get Matrix presence for {matrix_user_id}: {e}") + return {"presence": "offline"} + + async def get_matrix_presence_status(): """ Get Matrix presence from matrix-presence-aggregator. diff --git a/services/matrix-gateway/main.py b/services/matrix-gateway/main.py index 5da989cb..6ee30e45 100644 --- a/services/matrix-gateway/main.py +++ b/services/matrix-gateway/main.py @@ -611,6 +611,135 @@ async def get_room_messages(room_id: str, limit: int = 50): raise HTTPException(status_code=503, detail="Matrix unavailable") +# Presence Response Model +class GetPresenceResponse(BaseModel): + user_id: str + presence: str # "online" | "offline" | "unavailable" + last_active_ago_ms: Optional[int] = None + status_msg: Optional[str] = None + + +@app.get("/internal/matrix/presence/{matrix_user_id:path}") +async def get_user_presence(matrix_user_id: str): + """ + Get Matrix presence status for a user. + + Args: + matrix_user_id: Matrix user ID (e.g., @daarwizz:daarion.space) + + Returns: + Presence status: online, offline, or unavailable (away) + """ + admin_token = await get_admin_token() + + # URL encode the user_id for the path + encoded_user_id = matrix_user_id.replace("@", "%40").replace(":", "%3A") + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.get( + f"{settings.synapse_url}/_matrix/client/v3/presence/{encoded_user_id}/status", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + if resp.status_code == 200: + data = resp.json() + # Normalize presence state + raw_presence = data.get("presence", "offline") + if raw_presence == "online": + presence = "online" + elif raw_presence == "unavailable": + presence = "away" + else: + presence = "offline" + + return GetPresenceResponse( + user_id=matrix_user_id, + presence=presence, + last_active_ago_ms=data.get("last_active_ago"), + status_msg=data.get("status_msg") + ) + elif resp.status_code == 404: + # User not found or no presence info + logger.info(f"No presence info for {matrix_user_id}") + return GetPresenceResponse( + user_id=matrix_user_id, + presence="offline", + last_active_ago_ms=None + ) + else: + logger.warning(f"Presence query failed for {matrix_user_id}: {resp.status_code}") + return GetPresenceResponse( + user_id=matrix_user_id, + presence="offline", + last_active_ago_ms=None + ) + + except httpx.RequestError as e: + logger.error(f"Matrix request error: {e}") + return GetPresenceResponse( + user_id=matrix_user_id, + presence="offline", + last_active_ago_ms=None + ) + + +@app.get("/internal/matrix/presence/bulk") +async def get_bulk_presence(user_ids: str): + """ + Get presence for multiple users at once. + + Args: + user_ids: Comma-separated list of Matrix user IDs + + Returns: + List of presence statuses + """ + admin_token = await get_admin_token() + ids = [uid.strip() for uid in user_ids.split(",") if uid.strip()] + + results = [] + async with httpx.AsyncClient(timeout=30.0) as client: + for matrix_user_id in ids: + try: + encoded_user_id = matrix_user_id.replace("@", "%40").replace(":", "%3A") + resp = await client.get( + f"{settings.synapse_url}/_matrix/client/v3/presence/{encoded_user_id}/status", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + if resp.status_code == 200: + data = resp.json() + raw_presence = data.get("presence", "offline") + if raw_presence == "online": + presence = "online" + elif raw_presence == "unavailable": + presence = "away" + else: + presence = "offline" + + results.append({ + "user_id": matrix_user_id, + "presence": presence, + "last_active_ago_ms": data.get("last_active_ago") + }) + else: + results.append({ + "user_id": matrix_user_id, + "presence": "offline", + "last_active_ago_ms": None + }) + except Exception as e: + logger.warning(f"Failed to get presence for {matrix_user_id}: {e}") + results.append({ + "user_id": matrix_user_id, + "presence": "offline", + "last_active_ago_ms": None + }) + + return {"presences": results} + + @app.post("/internal/matrix/presence/online", response_model=SetPresenceResponse) async def set_presence_online(request: SetPresenceRequest): """