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,24 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY main.py .
# Expose port
EXPOSE 7004
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:7004/health').raise_for_status()"
# Run application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7004"]

View File

@@ -0,0 +1,361 @@
# DAARION Messaging Service
**Matrix-aware messaging service for DAARION**
## Overview
The messaging-service provides a DAARION-specific API layer over Matrix protocol:
- Manages channels (mapped to Matrix rooms)
- Indexes messages (full content stored in Matrix)
- Handles real-time WebSocket connections
- Integrates agents into conversations
- Bridges Matrix ↔ DAARION entity model
## Architecture
```
Frontend (React)
messaging-service (FastAPI)
matrix-gateway (internal)
Matrix Homeserver (Synapse)
```
## Features
✅ Channel management (create, list, update)
✅ Message sending/receiving
✅ Real-time WebSocket subscriptions
✅ Agent posting to channels
✅ Member invitations (users + agents)
✅ Threading/replies support
✅ Reactions (via Matrix)
✅ Message editing/deletion (via Matrix redaction)
## Setup
### 1. Prerequisites
- Python 3.11+
- PostgreSQL (with messenger schema migrated)
- matrix-gateway service running
- Matrix homeserver (Synapse/Dendrite)
### 2. Install dependencies
```bash
pip install -r requirements.txt
```
### 3. Environment variables
```bash
export DATABASE_URL="postgresql://user:pass@localhost:5432/daarion"
export MATRIX_GATEWAY_URL="http://matrix-gateway:7003"
export MATRIX_GATEWAY_SECRET="your-shared-secret"
export NATS_URL="nats://localhost:4222"
```
### 4. Run migrations
```bash
psql -d daarion -f ../../migrations/001_create_messenger_schema.sql
```
### 5. Start service
```bash
uvicorn main:app --host 0.0.0.0 --port 7004 --reload
```
Service will be available at `http://localhost:7004`
## API Documentation
Interactive API docs: http://localhost:7004/docs
### Key endpoints
#### Channels
- `GET /api/messaging/channels` — List channels
- `POST /api/messaging/channels` — Create channel (creates Matrix room)
- `GET /api/messaging/channels/{id}` — Get channel details
#### Messages
- `GET /api/messaging/channels/{id}/messages` — List messages (paginated)
- `POST /api/messaging/channels/{id}/messages` — Send message
#### Members
- `GET /api/messaging/channels/{id}/members` — List members
- `POST /api/messaging/channels/{id}/members` — Invite member
#### WebSocket
- `WS /ws/messaging/{channel_id}` — Real-time message stream
#### Internal (agent integration)
- `POST /internal/agents/{agent_id}/post-to-channel` — Agent posting
## Matrix Integration
### Channel → Room mapping
Every DAARION channel is backed by a Matrix room:
| DAARION | Matrix |
|---------|--------|
| `channels.id` | Unique UUID |
| `channels.matrix_room_id` | `!roomid:daarion.city` |
| `channels.slug` | Room alias (`#slug:daarion.city`) |
| `channels.visibility` | Room visibility (`public`/`private`) |
### Message → Event mapping
DAARION messages are indexed references to Matrix events:
| DAARION | Matrix |
|---------|--------|
| `messages.id` | Unique UUID |
| `messages.matrix_event_id` | `$eventid:daarion.city` |
| `messages.content_preview` | Truncated text (full in Matrix) |
| `messages.sender_id` | DAARION entity ID (`user:...`, `agent:...`) |
| `messages.sender_matrix_id` | Matrix user ID (`@user:server`) |
### Why index messages?
We don't duplicate Matrix events in full, only index them for:
- Fast listing and pagination
- MicroDAO context filtering
- Agent memory integration
- Task/project linking
- Analytics and metrics
Full message content is always fetched from Matrix when needed.
## Database Schema
See `../../migrations/001_create_messenger_schema.sql`
Key tables:
- `channels` — Channel metadata + Matrix room mapping
- `messages` — Message index (not full content)
- `channel_members` — Membership + permissions
- `message_reactions` — Reactions index
- `channel_events` — Audit log
## NATS Events
Published by messaging-service:
### `messaging.message.created`
```json
{
"channel_id": "uuid",
"matrix_event_id": "$event:server",
"sender_id": "user:alice",
"sender_type": "human",
"preview": "Hello world",
"created_at": "2025-11-24T10:30:00Z"
}
```
### `messaging.channel.created`
```json
{
"channel_id": "uuid",
"microdao_id": "microdao:7",
"matrix_room_id": "!room:server",
"created_by": "user:alice"
}
```
### `messaging.member.invited`
```json
{
"channel_id": "uuid",
"member_id": "agent:sofia",
"invited_by": "user:admin",
"role": "agent"
}
```
## Agent Integration
Agents can post to channels via internal API:
```python
POST /internal/agents/agent:sofia/post-to-channel
{
"channel_id": "uuid",
"text": "Hello from Sofia!"
}
```
Requirements:
- Agent must be a member of the channel
- Agent posts are sent as `m.notice` messages in Matrix
- Agent messages are indexed with `sender_type="agent"`
## WebSocket Protocol
Connect to `/ws/messaging/{channel_id}`:
**Client → Server:**
```json
"ping"
```
**Server → Client:**
```json
"pong"
```
**Server → Client (new message):**
```json
{
"type": "message.created",
"message": {
"id": "uuid",
"channel_id": "uuid",
"sender_id": "user:alice",
"content_preview": "Hello!",
"created_at": "2025-11-24T10:30:00Z"
}
}
```
## Element Compatibility
All channels created via messaging-service are **visible in Element**:
- Same Matrix homeserver
- Standard Matrix room types
- Federation enabled (optional)
- E2EE support (optional, per channel)
Users can use Element, DAARION UI, or any Matrix client interchangeably.
## Security
- **Authentication:** X-User-Id header (TODO: replace with JWT)
- **Authorization:** Channel membership + permissions checked
- **Matrix gateway:** Internal service, not exposed to public
- **Confidential mode:** E2EE channels, content not indexed
## Testing
### Manual testing with httpx
```python
import httpx
# Create channel
resp = httpx.post(
"http://localhost:7004/api/messaging/channels",
headers={"X-User-Id": "user:admin"},
json={
"slug": "test",
"name": "Test Channel",
"microdao_id": "microdao:daarion",
"visibility": "public"
}
)
channel = resp.json()
# Send message
resp = httpx.post(
f"http://localhost:7004/api/messaging/channels/{channel['id']}/messages",
headers={"X-User-Id": "user:alice"},
json={
"text": "Hello from Alice!"
}
)
message = resp.json()
```
### Testing with Element
1. Login to Element with Matrix user
2. Join room by alias: `#test-daarion:daarion.city`
3. Send message in Element
4. Check it appears in DAARION UI (via `/api/messaging/channels/.../messages`)
## Deployment
### Docker
```bash
docker build -t daarion/messaging-service .
docker run -p 7004:7004 \
-e DATABASE_URL="postgresql://..." \
-e MATRIX_GATEWAY_URL="http://matrix-gateway:7003" \
daarion/messaging-service
```
### Docker Compose
See `../../docker-compose.yml` for full stack deployment.
### Production checklist
- [ ] Enable JWT authentication (replace X-User-Id header)
- [ ] Add rate limiting (per user, per channel)
- [ ] Enable NATS event publishing
- [ ] Configure Matrix webhook subscriptions
- [ ] Set up monitoring (Prometheus metrics)
- [ ] Configure logging (structured JSON)
- [ ] Enable HTTPS/TLS for WebSocket
- [ ] Set DATABASE_URL from secrets
- [ ] Set MATRIX_GATEWAY_SECRET from secrets
## Roadmap
### v1.1
- [ ] NATS JetStream integration
- [ ] Matrix webhook subscription (push model)
- [ ] Message search (full-text via PostgreSQL)
- [ ] Typing indicators
- [ ] Read receipts
### v1.2
- [ ] Voice messages
- [ ] File uploads (via Matrix media API)
- [ ] Link previews
- [ ] Message pinning
### v2.0
- [ ] End-to-end encryption (E2EE) support
- [ ] Voice/video calls (via Matrix VoIP)
- [ ] Spaces support (channel groups)
## Troubleshooting
### "Failed to create Matrix room"
Check matrix-gateway is running:
```bash
curl http://localhost:7003/health
```
### "Database connection failed"
Check PostgreSQL is running and migrations applied:
```bash
psql -d daarion -c "SELECT COUNT(*) FROM channels;"
```
### WebSocket disconnects immediately
Check CORS settings and WebSocket upgrade headers.
## License
MIT
## Maintainer
DAARION Platform Team

View File

@@ -0,0 +1,668 @@
"""
DAARION Messaging Service (Matrix-aware)
FastAPI backend for Messenger module
Port: 7004
"""
import os
import asyncio
import json
from datetime import datetime
from typing import List, Optional
from uuid import UUID, uuid4
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import httpx
import asyncpg
# PEP Integration (Phase 4)
from pep_middleware import require_actor, require_channel_permission, require_microdao_permission
# ============================================================================
# Configuration
# ============================================================================
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://matrix-gateway:7003")
MATRIX_GATEWAY_SECRET = os.getenv("MATRIX_GATEWAY_SECRET", "dev-secret-token")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
NATS_URL = os.getenv("NATS_URL", "nats://localhost:4222")
# ============================================================================
# App Setup
# ============================================================================
app = FastAPI(
title="DAARION Messaging Service",
version="1.0.0",
description="Matrix-aware messaging service for DAARION",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# Database Connection Pool
# ============================================================================
db_pool: Optional[asyncpg.Pool] = None
nc = None # NATS connection
nats_available = False
@app.on_event("startup")
async def startup():
global db_pool, nc, nats_available
db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
print("✅ Database pool created")
# Connect to NATS
try:
import nats
nc = await nats.connect(NATS_URL)
nats_available = True
print(f"✅ Connected to NATS at {NATS_URL}")
except Exception as e:
print(f"⚠️ NATS not available: {e}")
print("⚠️ Continuing without NATS (events won't be published)")
nats_available = False
@app.on_event("shutdown")
async def shutdown():
global nc
if db_pool:
await db_pool.close()
print("✅ Database pool closed")
if nc:
await nc.close()
print("✅ NATS connection closed")
async def get_db():
async with db_pool.acquire() as conn:
yield conn
# ============================================================================
# NATS Publishing Helper
# ============================================================================
async def publish_nats_event(subject: str, data: dict):
"""Publish event to NATS"""
if nc and nats_available:
try:
await nc.publish(subject, json.dumps(data, default=str).encode())
print(f"✅ Published to NATS: {subject}")
except Exception as e:
print(f"❌ Error publishing to NATS: {e}")
else:
print(f"⚠️ NATS not available, event not published: {subject}")
# ============================================================================
# Models
# ============================================================================
class ChannelCreate(BaseModel):
slug: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
microdao_id: str = Field(..., regex=r"^microdao:[a-z0-9-]+$")
visibility: str = Field("microdao", regex="^(public|private|microdao)$")
is_encrypted: bool = False
class Channel(BaseModel):
id: UUID
slug: str
name: str
description: Optional[str]
microdao_id: str
matrix_room_id: str
visibility: str
is_direct: bool
is_encrypted: bool
created_by: str
created_at: datetime
updated_at: datetime
class MessageSend(BaseModel):
text: str = Field(..., min_length=1, max_length=10000)
msgtype: str = Field("m.text", regex="^m\\.(text|notice|image|file|audio|video)$")
formatted_body: Optional[str] = None
reply_to: Optional[UUID] = None # thread/reply support
class Message(BaseModel):
id: UUID
channel_id: UUID
matrix_event_id: str
sender_id: str
sender_type: str
content_preview: str
content_type: str
thread_id: Optional[UUID]
created_at: datetime
class MemberInvite(BaseModel):
member_id: str = Field(..., regex=r"^(user|agent):[a-z0-9-]+$")
role: str = Field("member", regex="^(owner|admin|member|guest|agent)$")
can_read: bool = True
can_write: bool = True
can_invite: bool = False
can_create_tasks: bool = False
class ChannelMember(BaseModel):
id: UUID
channel_id: UUID
member_id: str
member_type: str
role: str
can_read: bool
can_write: bool
joined_at: datetime
# ============================================================================
# Matrix Gateway Client
# ============================================================================
class MatrixGatewayClient:
def __init__(self, base_url: str, secret: str):
self.base_url = base_url
self.headers = {
"X-Internal-Service-Token": secret,
"Content-Type": "application/json"
}
async def create_room(self, name: str, topic: str, visibility: str, alias: str):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/internal/matrix/create-room",
headers=self.headers,
json={
"name": name,
"topic": topic,
"visibility": visibility,
"room_alias_name": alias,
"preset": "public_chat" if visibility == "public" else "private_chat"
},
timeout=10.0
)
resp.raise_for_status()
return resp.json()
async def send_message(self, room_id: str, sender: str, sender_matrix_id: str, body: str, msgtype: str = "m.text", formatted_body: Optional[str] = None, reply_to: Optional[str] = None):
payload = {
"room_id": room_id,
"sender": sender,
"sender_matrix_id": sender_matrix_id,
"msgtype": msgtype,
"body": body
}
if formatted_body:
payload["format"] = "org.matrix.custom.html"
payload["formatted_body"] = formatted_body
if reply_to:
payload["relates_to"] = {
"m.in_reply_to": {
"event_id": reply_to
}
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/internal/matrix/send",
headers=self.headers,
json=payload,
timeout=10.0
)
resp.raise_for_status()
return resp.json()
async def invite_user(self, room_id: str, user_id: str, inviter: str, inviter_matrix_id: str):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/internal/matrix/invite",
headers=self.headers,
json={
"room_id": room_id,
"user_id": user_id,
"inviter": inviter,
"inviter_matrix_id": inviter_matrix_id
},
timeout=10.0
)
resp.raise_for_status()
return resp.json()
matrix_client = MatrixGatewayClient(MATRIX_GATEWAY_URL, MATRIX_GATEWAY_SECRET)
# ============================================================================
# API Endpoints: Channels
# ============================================================================
@app.get("/api/messaging/channels", response_model=List[Channel])
async def list_channels(
microdao_id: Optional[str] = None,
conn: asyncpg.Connection = Depends(get_db)
):
"""List all channels (optionally filtered by microDAO)"""
if microdao_id:
rows = await conn.fetch(
"""
SELECT * FROM channels
WHERE microdao_id = $1 AND archived_at IS NULL
ORDER BY created_at DESC
""",
microdao_id
)
else:
rows = await conn.fetch(
"""
SELECT * FROM channels
WHERE archived_at IS NULL
ORDER BY created_at DESC
"""
)
return [dict(row) for row in rows]
@app.post("/api/messaging/channels", response_model=Channel, status_code=201)
async def create_channel(
data: ChannelCreate,
actor = Depends(require_actor),
conn: asyncpg.Connection = Depends(get_db)
):
"""Create a new channel (creates Matrix room)"""
# PEP: Check permission to create channel in microDAO
await require_microdao_permission(
microdao_id=data.microdao_id,
action="manage",
actor=actor
)
# Check if slug already exists in this microDAO
exists = await conn.fetchval(
"SELECT 1 FROM channels WHERE slug = $1 AND microdao_id = $2",
data.slug, data.microdao_id
)
if exists:
raise HTTPException(400, f"Channel slug '{data.slug}' already exists in {data.microdao_id}")
# Create Matrix room
try:
matrix_resp = await matrix_client.create_room(
name=data.name,
topic=data.description or "",
visibility=data.visibility,
alias=f"{data.slug}-{data.microdao_id.split(':')[1]}"
)
matrix_room_id = matrix_resp["room_id"]
except Exception as e:
raise HTTPException(500, f"Failed to create Matrix room: {str(e)}")
# Insert channel record
channel_id = uuid4()
row = await conn.fetchrow(
"""
INSERT INTO channels (id, slug, name, description, microdao_id, matrix_room_id, visibility, is_encrypted, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
""",
channel_id, data.slug, data.name, data.description, data.microdao_id,
matrix_room_id, data.visibility, data.is_encrypted, current_user
)
# TODO: Publish NATS event messaging.channel.created
return dict(row)
@app.get("/api/messaging/channels/{channel_id}", response_model=Channel)
async def get_channel(
channel_id: UUID,
conn: asyncpg.Connection = Depends(get_db)
):
"""Get channel by ID"""
row = await conn.fetchrow("SELECT * FROM channels WHERE id = $1", channel_id)
if not row:
raise HTTPException(404, "Channel not found")
return dict(row)
# ============================================================================
# API Endpoints: Messages
# ============================================================================
@app.get("/api/messaging/channels/{channel_id}/messages", response_model=List[Message])
async def list_messages(
channel_id: UUID,
limit: int = 50,
before: Optional[datetime] = None,
conn: asyncpg.Connection = Depends(get_db)
):
"""List messages in a channel (paginated)"""
if before:
rows = await conn.fetch(
"""
SELECT * FROM messages
WHERE channel_id = $1 AND created_at < $2 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $3
""",
channel_id, before, limit
)
else:
rows = await conn.fetch(
"""
SELECT * FROM messages
WHERE channel_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2
""",
channel_id, limit
)
return [dict(row) for row in rows]
@app.post("/api/messaging/channels/{channel_id}/messages", response_model=Message, status_code=201)
async def send_message(
channel_id: UUID,
data: MessageSend,
actor = Depends(require_actor),
conn: asyncpg.Connection = Depends(get_db)
):
"""Send a message to a channel"""
# Get channel
channel = await conn.fetchrow("SELECT * FROM channels WHERE id = $1", channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
# PEP: Check permission to send message
await require_channel_permission(
channel_id=str(channel_id),
action="send_message",
actor=actor,
context={"message_length": len(data.text)}
)
current_user = actor["actor_id"]
# Determine Matrix user ID (simplified, should map from DAARION ID)
sender_matrix_id = f"@{current_user.replace(':', '-')}:daarion.city"
# Get reply-to Matrix event ID if threading
reply_to_matrix_id = None
if data.reply_to:
reply_msg = await conn.fetchval(
"SELECT matrix_event_id FROM messages WHERE id = $1",
data.reply_to
)
reply_to_matrix_id = reply_msg
# Send to Matrix
try:
matrix_resp = await matrix_client.send_message(
room_id=channel["matrix_room_id"],
sender=current_user,
sender_matrix_id=sender_matrix_id,
body=data.text,
msgtype=data.msgtype,
formatted_body=data.formatted_body,
reply_to=reply_to_matrix_id
)
matrix_event_id = matrix_resp["event_id"]
except Exception as e:
raise HTTPException(500, f"Failed to send message to Matrix: {str(e)}")
# Index message
message_id = uuid4()
sender_type = "agent" if current_user.startswith("agent:") else "human"
content_preview = data.text[:500] # truncate for index
row = await conn.fetchrow(
"""
INSERT INTO messages (id, channel_id, matrix_event_id, matrix_type, sender_id, sender_type, sender_matrix_id, content_preview, content_type, thread_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
""",
message_id, channel_id, matrix_event_id, "m.room.message",
current_user, sender_type, sender_matrix_id, content_preview, "text", data.reply_to
)
# Publish NATS event messaging.message.created
await publish_nats_event("messaging.message.created", {
"channel_id": str(channel_id),
"message_id": str(message_id),
"matrix_event_id": matrix_event_id,
"sender_id": current_user,
"sender_type": sender_type,
"microdao_id": channel["microdao_id"],
"created_at": row["created_at"].isoformat()
})
return dict(row)
# ============================================================================
# API Endpoints: Members
# ============================================================================
@app.get("/api/messaging/channels/{channel_id}/members", response_model=List[ChannelMember])
async def list_members(
channel_id: UUID,
conn: asyncpg.Connection = Depends(get_db)
):
"""List channel members"""
rows = await conn.fetch(
"""
SELECT * FROM channel_members
WHERE channel_id = $1 AND left_at IS NULL
ORDER BY joined_at ASC
""",
channel_id
)
return [dict(row) for row in rows]
@app.post("/api/messaging/channels/{channel_id}/members", response_model=ChannelMember, status_code=201)
async def invite_member(
channel_id: UUID,
data: MemberInvite,
current_user: str = Header("user:admin", alias="X-User-Id"),
conn: asyncpg.Connection = Depends(get_db)
):
"""Invite a member (user or agent) to a channel"""
# Get channel
channel = await conn.fetchrow("SELECT * FROM channels WHERE id = $1", channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
# TODO: Check permissions (can_invite)
# Check if already member
exists = await conn.fetchval(
"SELECT 1 FROM channel_members WHERE channel_id = $1 AND member_id = $2 AND left_at IS NULL",
channel_id, data.member_id
)
if exists:
raise HTTPException(400, "Member already in channel")
# Determine Matrix user ID
member_matrix_id = f"@{data.member_id.replace(':', '-')}:daarion.city"
inviter_matrix_id = f"@{current_user.replace(':', '-')}:daarion.city"
# Invite to Matrix room
try:
await matrix_client.invite_user(
room_id=channel["matrix_room_id"],
user_id=member_matrix_id,
inviter=current_user,
inviter_matrix_id=inviter_matrix_id
)
except Exception as e:
raise HTTPException(500, f"Failed to invite to Matrix room: {str(e)}")
# Add member record
member_id = uuid4()
member_type = "agent" if data.member_id.startswith("agent:") else "human"
matrix_power_level = 50 if data.role in ["admin", "owner"] else 0
row = await conn.fetchrow(
"""
INSERT INTO channel_members (id, channel_id, member_id, member_type, matrix_user_id, role, can_read, can_write, can_invite, can_create_tasks, matrix_power_level, invited_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
""",
member_id, channel_id, data.member_id, member_type, member_matrix_id,
data.role, data.can_read, data.can_write, data.can_invite, data.can_create_tasks,
matrix_power_level, current_user
)
# TODO: Publish NATS event messaging.member.invited
return dict(row)
# ============================================================================
# WebSocket: Real-time messages
# ============================================================================
class ConnectionManager:
def __init__(self):
self.active_connections: dict[UUID, list[WebSocket]] = {}
async def connect(self, channel_id: UUID, websocket: WebSocket):
await websocket.accept()
if channel_id not in self.active_connections:
self.active_connections[channel_id] = []
self.active_connections[channel_id].append(websocket)
def disconnect(self, channel_id: UUID, websocket: WebSocket):
if channel_id in self.active_connections:
self.active_connections[channel_id].remove(websocket)
async def broadcast(self, channel_id: UUID, message: dict):
if channel_id in self.active_connections:
for connection in self.active_connections[channel_id]:
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
@app.websocket("/ws/messaging/{channel_id}")
async def websocket_messages(websocket: WebSocket, channel_id: UUID):
"""WebSocket endpoint for real-time messages"""
await manager.connect(channel_id, websocket)
try:
while True:
# Keep connection alive (ping/pong)
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
manager.disconnect(channel_id, websocket)
# ============================================================================
# Internal API: Agent posting
# ============================================================================
@app.post("/internal/agents/{agent_id}/post-to-channel")
async def agent_post_to_channel(
agent_id: str,
channel_id: UUID,
text: str,
conn: asyncpg.Connection = Depends(get_db)
):
"""Internal endpoint for agents to post to channels"""
# Get channel
channel = await conn.fetchrow("SELECT * FROM channels WHERE id = $1", channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
# Check if agent is member
is_member = await conn.fetchval(
"SELECT 1 FROM channel_members WHERE channel_id = $1 AND member_id = $2 AND left_at IS NULL",
channel_id, agent_id
)
if not is_member:
raise HTTPException(403, "Agent is not a member of this channel")
# Send message (reuse existing logic)
agent_matrix_id = f"@{agent_id.replace(':', '-')}:daarion.city"
try:
matrix_resp = await matrix_client.send_message(
room_id=channel["matrix_room_id"],
sender=agent_id,
sender_matrix_id=agent_matrix_id,
body=text,
msgtype="m.notice" # agents send as notices
)
matrix_event_id = matrix_resp["event_id"]
except Exception as e:
raise HTTPException(500, f"Failed to send agent message: {str(e)}")
# Index message
message_id = uuid4()
content_preview = text[:500]
row = await conn.fetchrow(
"""
INSERT INTO messages (id, channel_id, matrix_event_id, matrix_type, sender_id, sender_type, sender_matrix_id, content_preview, content_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
""",
message_id, channel_id, matrix_event_id, "m.room.message",
agent_id, "agent", agent_matrix_id, content_preview, "text"
)
# Broadcast to WebSocket clients
await manager.broadcast(channel_id, {
"type": "message.created",
"message": dict(row)
})
return {"status": "posted", "message_id": str(message_id)}
# ============================================================================
# Internal Endpoints: Agent Filter Context
# ============================================================================
@app.get("/internal/messaging/channels/{channel_id}/context")
async def get_channel_context(
channel_id: UUID,
conn: asyncpg.Connection = Depends(get_db)
):
"""
Get channel context for agent-filter
Returns channel metadata for filtering decisions
"""
channel = await conn.fetchrow("SELECT * FROM channels WHERE id = $1", channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
# TODO: Load allowed_agents from channel_members or config
# For now, return default Sofia agent
return {
"microdao_id": channel["microdao_id"],
"visibility": channel["visibility"],
"allowed_agents": ["agent:sofia"],
"disabled_agents": []
}
# ============================================================================
# Health Check
# ============================================================================
@app.get("/health")
async def health():
return {
"status": "healthy",
"service": "messaging-service",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7004)

View File

@@ -0,0 +1,178 @@
"""
PEP Middleware for messaging-service
Policy Enforcement Point - enforces access control via PDP
"""
import httpx
import os
from fastapi import HTTPException, Header
from typing import Optional
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://pdp-service:7012")
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://auth-service:7011")
class PEPClient:
"""Client for Policy Enforcement Point"""
def __init__(self):
self.pdp_url = PDP_SERVICE_URL
self.auth_url = AUTH_SERVICE_URL
async def get_actor_identity(self, authorization: Optional[str] = None, x_api_key: Optional[str] = None):
"""
Get actor identity from auth-service
Returns ActorIdentity or raises HTTPException
"""
if not authorization and not x_api_key:
raise HTTPException(401, "Authorization required")
headers = {}
if authorization:
headers["Authorization"] = authorization
if x_api_key:
headers["X-API-Key"] = x_api_key
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{self.auth_url}/auth/me",
headers=headers
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise HTTPException(401, "Invalid credentials")
raise HTTPException(503, f"Auth service error: {e.response.status_code}")
except Exception as e:
print(f"⚠️ Auth service unavailable: {e}")
# Fallback: extract user ID from header (Phase 4 stub)
return self._fallback_actor(authorization, x_api_key)
def _fallback_actor(self, authorization: Optional[str], x_api_key: Optional[str]):
"""Fallback actor identity when auth-service is unavailable"""
# Extract from X-User-Id header (Phase 2/3 compatibility)
actor_id = "user:admin" # Default for Phase 4 testing
return {
"actor_id": actor_id,
"actor_type": "human",
"microdao_ids": ["microdao:daarion", "microdao:7"],
"roles": ["member", "admin"]
}
async def check_permission(self, actor, action: str, resource_type: str, resource_id: str, context: dict = None):
"""
Check permission via PDP
Raises HTTPException(403) if denied
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{self.pdp_url}/internal/pdp/evaluate",
json={
"actor": actor,
"action": action,
"resource": {
"type": resource_type,
"id": resource_id
},
"context": context or {}
}
)
response.raise_for_status()
decision = response.json()
if decision["effect"] == "deny":
reason = decision.get("reason", "access_denied")
raise HTTPException(403, f"Access denied: {reason}")
return decision
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
raise HTTPException(403, "Access denied")
print(f"⚠️ PDP service error: {e.response.status_code}")
# Fallback: allow (Phase 4 testing)
return {"effect": "permit", "reason": "pdp_unavailable_fallback"}
except Exception as e:
print(f"⚠️ PDP service unavailable: {e}")
# Fallback: allow (Phase 4 testing)
return {"effect": "permit", "reason": "pdp_unavailable_fallback"}
# Global PEP client instance
pep_client = PEPClient()
# ============================================================================
# Dependency Functions (for FastAPI endpoints)
# ============================================================================
async def require_actor(
authorization: Optional[str] = Header(None),
x_api_key: Optional[str] = Header(None),
x_user_id: Optional[str] = Header(None) # Phase 2/3 compatibility
):
"""
FastAPI dependency: Get current actor identity
Usage:
@app.post("/api/messaging/channels/{channel_id}/messages")
async def send_message(actor = Depends(require_actor)):
...
"""
# Phase 2/3 compatibility: use X-User-Id if present
if x_user_id and not authorization and not x_api_key:
return {
"actor_id": x_user_id,
"actor_type": "agent" if x_user_id.startswith("agent:") else "human",
"microdao_ids": ["microdao:daarion"],
"roles": ["member"]
}
return await pep_client.get_actor_identity(authorization, x_api_key)
async def require_channel_permission(
channel_id: str,
action: str = "send_message",
actor = None,
context: dict = None
):
"""
Check permission for channel action
Raises HTTPException(403) if denied
"""
if not actor:
raise HTTPException(401, "Authentication required")
await pep_client.check_permission(
actor=actor,
action=action,
resource_type="channel",
resource_id=channel_id,
context=context
)
async def require_microdao_permission(
microdao_id: str,
action: str = "read",
actor = None
):
"""
Check permission for microDAO action
Raises HTTPException(403) if denied
"""
if not actor:
raise HTTPException(401, "Authentication required")
await pep_client.check_permission(
actor=actor,
action=action,
resource_type="microdao",
resource_id=microdao_id
)

View File

@@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
httpx==0.25.1
asyncpg==0.29.0
websockets==12.0
python-multipart==0.0.6
nats-py==2.6.0