feat: Presence Layer API implementation
Matrix Gateway:
- GET /internal/matrix/presence/{matrix_user_id} - get single user presence
- GET /internal/matrix/presence/bulk - get multiple users presence
City Service:
- GET /api/v1/agents/{agent_id}/presence - get agent presence via gateway
- get_matrix_presence_for_user() helper function
Task doc: TASK_PHASE_PRESENCE_LAYER_v1.md
This commit is contained in:
228
docs/tasks/TASK_PHASE_PRESENCE_LAYER_v1.md
Normal file
228
docs/tasks/TASK_PHASE_PRESENCE_LAYER_v1.md
Normal file
@@ -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": <number|null>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логіка:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
`<PresenceDot state="online|offline|away" />`
|
||||||
|
|
||||||
|
- online → зелений
|
||||||
|
- away → жовтий
|
||||||
|
- offline → сірий
|
||||||
|
|
||||||
|
## 5.3. Інтеграції
|
||||||
|
|
||||||
|
### `/agents`
|
||||||
|
|
||||||
|
У таблиці списку агентів:
|
||||||
|
|
||||||
|
- додати `<PresenceDot>` зліва від аватарки.
|
||||||
|
|
||||||
|
### `/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_<DATE>.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_<DATE>.md
|
||||||
|
|
||||||
|
Орієнтуйся на foundation-документи у docs/foundation/.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Target Date**: Immediate
|
||||||
|
**Priority**: Critical
|
||||||
|
**Dependencies**: Matrix Gateway running, Synapse accessible
|
||||||
|
|
||||||
@@ -1736,6 +1736,61 @@ async def get_agents_presence():
|
|||||||
raise HTTPException(status_code=500, detail="Failed to 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():
|
async def get_matrix_presence_status():
|
||||||
"""
|
"""
|
||||||
Get Matrix presence from matrix-presence-aggregator.
|
Get Matrix presence from matrix-presence-aggregator.
|
||||||
|
|||||||
@@ -611,6 +611,135 @@ async def get_room_messages(room_id: str, limit: int = 50):
|
|||||||
raise HTTPException(status_code=503, detail="Matrix unavailable")
|
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)
|
@app.post("/internal/matrix/presence/online", response_model=SetPresenceResponse)
|
||||||
async def set_presence_online(request: SetPresenceRequest):
|
async def set_presence_online(request: SetPresenceRequest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user