feat: Add presence heartbeat for Matrix online status

- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,2 @@
# Adapters for Living Map Service

View File

@@ -0,0 +1,61 @@
"""
Agents Service Client Adapter
Phase 9: Living Map
"""
from typing import List, Dict, Any
from .base_client import BaseServiceClient
import os
class AgentsClient(BaseServiceClient):
"""Client for agents-service"""
def __init__(self):
base_url = os.getenv("AGENTS_SERVICE_URL", "http://localhost:7014")
super().__init__(base_url)
async def get_agents_list(self) -> List[Dict[str, Any]]:
"""Get list of all agents"""
result = await self.get_with_fallback("/agents", fallback=[])
return result if isinstance(result, list) else []
async def get_agent_metrics_summary(self) -> Dict[str, Any]:
"""Get aggregated agent metrics"""
# This endpoint might not exist yet, use fallback
result = await self.get_with_fallback("/agents/metrics/summary", fallback={
"total_agents": 0,
"online_agents": 0,
"total_llm_calls_24h": 0,
"total_tokens_24h": 0
})
return result
async def get_layer_data(self) -> Dict[str, Any]:
"""Get agents layer data for Living Map"""
agents_list = await self.get_agents_list()
metrics_summary = await self.get_agent_metrics_summary()
# Transform to Living Map format
items = []
for agent in agents_list:
items.append({
"id": agent.get("id", agent.get("external_id", "unknown")),
"name": agent.get("name", "Unknown Agent"),
"kind": agent.get("kind", "assistant"),
"microdao_id": agent.get("microdao_id"),
"status": "online" if agent.get("is_active") else "offline",
"usage": {
"llm_calls_24h": 0, # TODO: Get from usage-engine
"tokens_24h": 0,
"messages_24h": 0
},
"model": agent.get("model"),
"last_active": agent.get("updated_at")
})
return {
"items": items,
"total_agents": len(items),
"online_agents": sum(1 for a in items if a["status"] == "online"),
"total_llm_calls_24h": metrics_summary.get("total_llm_calls_24h", 0)
}

View File

@@ -0,0 +1,51 @@
"""
Base HTTP Client for service adapters
Phase 9: Living Map
"""
import httpx
from typing import Optional, Any
import asyncio
class BaseServiceClient:
"""Base client with timeout and retry logic"""
def __init__(self, base_url: str, timeout: float = 5.0):
self.base_url = base_url.rstrip('/')
self.timeout = timeout
async def get(
self,
path: str,
params: Optional[dict] = None,
headers: Optional[dict] = None
) -> Optional[Any]:
"""GET request with error handling"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}{path}",
params=params,
headers=headers or {}
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
print(f"⚠️ Timeout calling {self.base_url}{path}")
return None
except httpx.HTTPError as e:
print(f"⚠️ HTTP error calling {self.base_url}{path}: {e}")
return None
except Exception as e:
print(f"⚠️ Error calling {self.base_url}{path}: {e}")
return None
async def get_with_fallback(
self,
path: str,
fallback: Any,
params: Optional[dict] = None
) -> Any:
"""GET with fallback value if fails"""
result = await self.get(path, params)
return result if result is not None else fallback

View File

@@ -0,0 +1,34 @@
"""
City Service Client Adapter
Phase 9: Living Map
"""
from typing import Dict, Any
from .base_client import BaseServiceClient
import os
class CityClient(BaseServiceClient):
"""Client for city-service"""
def __init__(self):
base_url = os.getenv("CITY_SERVICE_URL", "http://localhost:7001")
super().__init__(base_url)
async def get_city_snapshot(self) -> Dict[str, Any]:
"""Get city snapshot"""
result = await self.get_with_fallback("/api/city/snapshot", fallback={})
return result
async def get_layer_data(self) -> Dict[str, Any]:
"""Get city layer data for Living Map"""
snapshot = await self.get_city_snapshot()
# Return as-is or transform if needed
# This is a placeholder - actual structure depends on city-service
return snapshot if snapshot else {
"microdaos_total": 0,
"active_users": 0,
"active_agents": 0,
"health": "unknown",
"items": []
}

View File

@@ -0,0 +1,50 @@
"""
DAO Service Client Adapter
Phase 9: Living Map
"""
from typing import List, Dict, Any
from .base_client import BaseServiceClient
import os
class DaoClient(BaseServiceClient):
"""Client for dao-service"""
def __init__(self):
base_url = os.getenv("DAO_SERVICE_URL", "http://localhost:7016")
super().__init__(base_url)
async def get_daos_list(self) -> List[Dict[str, Any]]:
"""Get list of all DAOs"""
result = await self.get_with_fallback("/dao", fallback=[])
return result if isinstance(result, list) else []
async def get_proposals_summary(self) -> Dict[str, Any]:
"""Get proposals summary across all DAOs"""
# This endpoint might not exist, return placeholder
return {
"total_proposals": 0,
"active_proposals": 0
}
async def get_layer_data(self) -> Dict[str, Any]:
"""Get space layer data for Living Map (DAO planets)"""
daos_list = await self.get_daos_list()
# Transform to Living Map format (DAOs as planets)
planets = []
for dao in daos_list:
planets.append({
"id": f"dao:{dao.get('slug', dao.get('id'))}",
"name": dao.get("name", "Unknown DAO"),
"type": "dao",
"status": "active" if dao.get("is_active") else "inactive",
"orbits": [], # TODO: Link nodes to DAOs
"treasury_value": None,
"active_proposals": 0
})
return {
"planets": planets,
"nodes": [] # Nodes will be added from space-service
}

View File

@@ -0,0 +1,61 @@
"""
MicroDAO Service Client Adapter
Phase 9: Living Map
"""
from typing import List, Dict, Any
from .base_client import BaseServiceClient
import os
class MicrodaoClient(BaseServiceClient):
"""Client for microdao-service"""
def __init__(self):
base_url = os.getenv("MICRODAO_SERVICE_URL", "http://localhost:7015")
super().__init__(base_url)
async def get_microdaos_list(self) -> List[Dict[str, Any]]:
"""Get list of all microDAOs"""
# Note: This endpoint might require auth, using internal endpoint if available
result = await self.get_with_fallback("/internal/microdaos", fallback=[])
if not result:
result = await self.get_with_fallback("/microdao", fallback=[])
return result if isinstance(result, list) else []
async def get_layer_data(self) -> Dict[str, Any]:
"""Get city layer data for Living Map"""
microdaos_list = await self.get_microdaos_list()
# Transform to Living Map format
items = []
total_agents = 0
total_nodes = 0
total_members = 0
for microdao in microdaos_list:
agents_count = microdao.get("agent_count", 0)
nodes_count = microdao.get("node_count", 0)
members_count = microdao.get("member_count", 0)
total_agents += agents_count
total_nodes += nodes_count
total_members += members_count
items.append({
"id": microdao.get("external_id", f"microdao:{microdao.get('id')}"),
"slug": microdao.get("slug", "unknown"),
"name": microdao.get("name", "Unknown microDAO"),
"status": "active" if microdao.get("is_active") else "inactive",
"agents": agents_count,
"nodes": nodes_count,
"members": members_count,
"description": microdao.get("description")
})
return {
"microdaos_total": len(items),
"active_users": total_members,
"active_agents": total_agents,
"health": "green" if len(items) > 0 else "yellow",
"items": items
}

View File

@@ -0,0 +1,34 @@
"""
Space Service Client Adapter
Phase 9: Living Map
"""
from typing import Dict, Any
from .base_client import BaseServiceClient
import os
class SpaceClient(BaseServiceClient):
"""Client for space-service"""
def __init__(self):
base_url = os.getenv("SPACE_SERVICE_URL", "http://localhost:7002")
super().__init__(base_url)
async def get_space_scene(self) -> Dict[str, Any]:
"""Get space scene data"""
result = await self.get_with_fallback("/api/space/scene", fallback={})
return result
async def get_layer_data(self) -> Dict[str, Any]:
"""Get space layer data for Living Map"""
scene = await self.get_space_scene()
# Extract planets and nodes from scene
# Format depends on space-service implementation
planets = scene.get("planets", [])
nodes = scene.get("nodes", [])
return {
"planets": planets,
"nodes": nodes
}

View File

@@ -0,0 +1,40 @@
"""
Usage Engine Client Adapter
Phase 9: Living Map
"""
from typing import Dict, Any
from .base_client import BaseServiceClient
import os
class UsageClient(BaseServiceClient):
"""Client for usage-engine"""
def __init__(self):
base_url = os.getenv("USAGE_ENGINE_URL", "http://localhost:7013")
super().__init__(base_url)
async def get_usage_summary(self, period_hours: int = 24) -> Dict[str, Any]:
"""Get usage summary for period"""
result = await self.get_with_fallback(
"/internal/usage/summary",
fallback={
"total_llm_calls": 0,
"total_tokens": 0,
"period_hours": period_hours
},
params={"period_hours": period_hours}
)
return result
async def get_agent_usage(self, agent_id: str) -> Dict[str, Any]:
"""Get usage for specific agent"""
result = await self.get_with_fallback(
f"/internal/usage/agent/{agent_id}",
fallback={
"llm_calls_24h": 0,
"tokens_24h": 0,
"messages_24h": 0
}
)
return result