feat: Implement Matrix Rooms Bridge
- MATRIX_ROOMS_BRIDGE_SPEC.md documentation - Migration 012: Add matrix_room_id/alias to city_rooms - Matrix Gateway service (port 7025) - City-service: auto-create Matrix rooms on room creation - Backfill endpoint for existing rooms - API returns matrix_room_id/alias in room responses
This commit is contained in:
361
docs/matrix/MATRIX_ROOMS_BRIDGE_SPEC.md
Normal file
361
docs/matrix/MATRIX_ROOMS_BRIDGE_SPEC.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# MATRIX ROOMS BRIDGE — DAARION.city
|
||||||
|
|
||||||
|
Version: 1.0.0
|
||||||
|
|
||||||
|
## 0. PURPOSE
|
||||||
|
|
||||||
|
Звʼязати City Rooms у DAARION з Matrix-кімнатами так, щоб:
|
||||||
|
|
||||||
|
- кожна `city_room` мала свій `matrix_room_id` / `matrix_room_alias`,
|
||||||
|
- UI для `/city/[slug]` працював поверх справжньої Matrix-кімнати,
|
||||||
|
- подальший presence/typing/реальний чат використовували Matrix як єдине джерело істини.
|
||||||
|
|
||||||
|
Це база для:
|
||||||
|
|
||||||
|
- живих чатів у City,
|
||||||
|
- спільної присутності,
|
||||||
|
- інтеграції агентів як Matrix-botʼів.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ARCHITECTURE OVERVIEW
|
||||||
|
|
||||||
|
### Сервіси
|
||||||
|
|
||||||
|
- **city-service** (7001)
|
||||||
|
- зберігає список кімнат (rooms)
|
||||||
|
- розширюється полями для Matrix
|
||||||
|
|
||||||
|
- **matrix-gateway** (новий сервіс, 7025)
|
||||||
|
- проксі до Synapse REST API
|
||||||
|
- хендлить auth до Matrix від імені DAARION
|
||||||
|
|
||||||
|
- **auth-service** (7020)
|
||||||
|
- вже створює Matrix акаунти для юзерів (auto-provisioning)
|
||||||
|
|
||||||
|
- **synapse** (8018)
|
||||||
|
- Matrix homeserver
|
||||||
|
|
||||||
|
### Мапінг
|
||||||
|
|
||||||
|
Кожна City Room має:
|
||||||
|
|
||||||
|
| Поле | Опис | Приклад |
|
||||||
|
|------|------|---------|
|
||||||
|
| `room_id` | внутрішній DAARION id | `room_city_general` |
|
||||||
|
| `slug` | URL/імена кімнати | `general` |
|
||||||
|
| `matrix_room_id` | Matrix ID | `!abc123xyz:daarion.space` |
|
||||||
|
| `matrix_room_alias` | Matrix alias | `#city_general:daarion.space` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DATA MODEL CHANGES (PostgreSQL)
|
||||||
|
|
||||||
|
### 2.1. Таблиця city_rooms
|
||||||
|
|
||||||
|
Додати поля:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE city_rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS matrix_room_id TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS matrix_room_alias TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS city_rooms_matrix_room_id_uq
|
||||||
|
ON city_rooms (matrix_room_id)
|
||||||
|
WHERE matrix_room_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS city_rooms_matrix_room_alias_uq
|
||||||
|
ON city_rooms (matrix_room_alias)
|
||||||
|
WHERE matrix_room_alias IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. Invariants
|
||||||
|
|
||||||
|
* `matrix_room_id` → або `NULL`, або валідний Matrix room id (`!....:domain`)
|
||||||
|
* `matrix_room_alias` → або `NULL`, або вигляду `#city_<slug>:daarion.space`
|
||||||
|
* Один Matrix room = одна City room
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. NAMING CONVENTION
|
||||||
|
|
||||||
|
Для Matrix-кімнат:
|
||||||
|
|
||||||
|
* **room alias**:
|
||||||
|
* формат: `#city_<slug>:daarion.space`
|
||||||
|
* приклад: `#city_general:daarion.space`
|
||||||
|
|
||||||
|
* **room name**:
|
||||||
|
* `"DAARION City — <Room Name>"`
|
||||||
|
* приклад: `"DAARION City — General"`
|
||||||
|
|
||||||
|
Ці назви видно в Matrix-клієнтах (Element Web).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FLOWS
|
||||||
|
|
||||||
|
### 4.1. Створення нової City Room
|
||||||
|
|
||||||
|
Коли в `city-service` створюється новий room:
|
||||||
|
|
||||||
|
1. Генерується `slug` (як є зараз).
|
||||||
|
|
||||||
|
2. Викликається Matrix Gateway:
|
||||||
|
`POST /internal/matrix/rooms/create`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "energy",
|
||||||
|
"name": "Energy",
|
||||||
|
"visibility": "public"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Matrix Gateway:
|
||||||
|
* викликає Synapse API `POST /_matrix/client/v3/createRoom`
|
||||||
|
* задає:
|
||||||
|
* `name: "DAARION City — Energy"`
|
||||||
|
* `room_alias_name: "city_energy"`
|
||||||
|
* `preset: "public_chat"`
|
||||||
|
* повертає:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"matrix_room_id": "!abc123:daarion.space",
|
||||||
|
"matrix_room_alias": "#city_energy:daarion.space"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. `city-service` зберігає ці значення в `city_rooms`.
|
||||||
|
|
||||||
|
**Якщо Matrix недоступний:**
|
||||||
|
* MVP: fail створення кімнати цілком, щоб не було "неповних" кімнат.
|
||||||
|
* TODO: retry-механізм для production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2. Синхронізація існуючих кімнат (backfill)
|
||||||
|
|
||||||
|
Для вже створених `city_rooms`:
|
||||||
|
|
||||||
|
1. Endpoint: `POST /internal/city/matrix/backfill`:
|
||||||
|
* бере всі кімнати, де `matrix_room_id IS NULL`.
|
||||||
|
* для кожної:
|
||||||
|
* пробує знайти Matrix room по alias `#city_<slug>:daarion.space` через Matrix Gateway:
|
||||||
|
* якщо знайдено → зберігає `matrix_room_id` / `matrix_room_alias`;
|
||||||
|
* якщо не знайдено → створює нову Matrix room, як у п. 4.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3. Архівація / деактивація Room
|
||||||
|
|
||||||
|
Коли Room в DAARION позначається як "archived" / "inactive":
|
||||||
|
|
||||||
|
MVP:
|
||||||
|
* не видаляти Matrix room,
|
||||||
|
* показувати статус в DAARION UI як `archived`.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
* змінити power levels,
|
||||||
|
* закрити можливість писати,
|
||||||
|
* додати `m.room.tombstone`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MATRIX GATEWAY SERVICE
|
||||||
|
|
||||||
|
### 5.1. Конфігурація
|
||||||
|
|
||||||
|
```env
|
||||||
|
MATRIX_GATEWAY_PORT=7025
|
||||||
|
SYNAPSE_URL=http://daarion-synapse:8008
|
||||||
|
SYNAPSE_ADMIN_TOKEN=<admin_access_token>
|
||||||
|
MATRIX_SERVER_NAME=daarion.space
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2. API Endpoints
|
||||||
|
|
||||||
|
#### `POST /internal/matrix/rooms/create`
|
||||||
|
|
||||||
|
Викликається тільки з `city-service`.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "energy",
|
||||||
|
"name": "Energy",
|
||||||
|
"visibility": "public"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"matrix_room_id": "!abc123:daarion.space",
|
||||||
|
"matrix_room_alias": "#city_energy:daarion.space"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (500):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "matrix_unavailable",
|
||||||
|
"detail": "Failed to create Matrix room"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /internal/matrix/rooms/find-by-alias`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
`GET /internal/matrix/rooms/find-by-alias?alias=%23city_energy%3Adaarion.space`
|
||||||
|
|
||||||
|
**Response (200, exists):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"matrix_room_id": "!abc123:daarion.space",
|
||||||
|
"matrix_room_alias": "#city_energy:daarion.space"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (404, not found):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "not_found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /healthz`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"synapse": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CITY-SERVICE API CHANGES
|
||||||
|
|
||||||
|
### 6.1. `GET /api/city/rooms`
|
||||||
|
|
||||||
|
Додати у відповідь:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "room_city_general",
|
||||||
|
"slug": "general",
|
||||||
|
"name": "General",
|
||||||
|
"description": "Головна кімната міста",
|
||||||
|
"is_default": true,
|
||||||
|
"members_online": 42,
|
||||||
|
"last_event": "2025-11-26T20:00:00Z",
|
||||||
|
"matrix_room_id": "!abc123:daarion.space",
|
||||||
|
"matrix_room_alias": "#city_general:daarion.space"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2. `GET /api/city/rooms/{slug}`
|
||||||
|
|
||||||
|
Також включити `matrix_room_id` / `matrix_room_alias`.
|
||||||
|
|
||||||
|
### 6.3. `POST /api/city/rooms`
|
||||||
|
|
||||||
|
При створенні кімнати автоматично створювати Matrix room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FRONTEND INTEGRATION
|
||||||
|
|
||||||
|
На сторінці `/city/[slug]`:
|
||||||
|
|
||||||
|
* при завантаженні room:
|
||||||
|
* отримати `matrix_room_id` / `matrix_room_alias`,
|
||||||
|
* передати їх у чат-Layout.
|
||||||
|
|
||||||
|
Чат-шар (`ChatRoom`) повинен:
|
||||||
|
* використовувати Matrix-клієнт для:
|
||||||
|
* приєднання до кімнати,
|
||||||
|
* отримання історії,
|
||||||
|
* відправки повідомлень.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. SECURITY
|
||||||
|
|
||||||
|
* `matrix-gateway` endpoint'и `/internal/matrix/*`:
|
||||||
|
* тільки internal auth (service-to-service)
|
||||||
|
* перевірка internal token або Docker network
|
||||||
|
|
||||||
|
* Кінцеві користувачі не повинні напряму бачити Matrix admin-credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. DEPLOYMENT
|
||||||
|
|
||||||
|
### Docker Compose addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
matrix-gateway:
|
||||||
|
build: ./services/matrix-gateway
|
||||||
|
container_name: daarion-matrix-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MATRIX_GATEWAY_PORT=7025
|
||||||
|
- SYNAPSE_URL=http://daarion-synapse:8008
|
||||||
|
- SYNAPSE_ADMIN_TOKEN=${SYNAPSE_ADMIN_TOKEN}
|
||||||
|
- MATRIX_SERVER_NAME=daarion.space
|
||||||
|
ports:
|
||||||
|
- "7025:7025"
|
||||||
|
networks:
|
||||||
|
- dagi-network
|
||||||
|
depends_on:
|
||||||
|
- synapse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx routing
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /internal/matrix/ {
|
||||||
|
# Internal only - block external access
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:7025/internal/matrix/;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. ROADMAP AFTER BRIDGE
|
||||||
|
|
||||||
|
Після реалізації цього SPEC:
|
||||||
|
|
||||||
|
1. **Presence & Typing:**
|
||||||
|
* читати `m.presence`, `m.typing`, `m.receipt` з Matrix,
|
||||||
|
* транслювати в фронт (через WebSocket).
|
||||||
|
|
||||||
|
2. **Agents як Matrix Bot:**
|
||||||
|
* окремі Matrix accounts для агентів,
|
||||||
|
* відповіді агентів у тій же кімнаті, що й користувачі.
|
||||||
|
|
||||||
|
3. **City Map:**
|
||||||
|
* візуалізація активності кімнат на 2D/2.5D мапі.
|
||||||
|
|
||||||
|
4. **PWA/Mobile:**
|
||||||
|
* manifest.json,
|
||||||
|
* service worker,
|
||||||
|
* offline кеш.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. ACCEPTANCE CRITERIA
|
||||||
|
|
||||||
|
- [ ] `city_rooms` має поля `matrix_room_id` / `matrix_room_alias`
|
||||||
|
- [ ] При створенні City Room автоматично створюється Matrix room
|
||||||
|
- [ ] Backfill endpoint синхронізує існуючі кімнати
|
||||||
|
- [ ] API `/api/city/rooms` повертає matrix поля
|
||||||
|
- [ ] Matrix Gateway працює і відповідає на healthcheck
|
||||||
|
- [ ] Element Web показує створені кімнати
|
||||||
|
|
||||||
21
migrations/012_add_matrix_to_city_rooms.sql
Normal file
21
migrations/012_add_matrix_to_city_rooms.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration 012: Add Matrix fields to city_rooms
|
||||||
|
-- MATRIX ROOMS BRIDGE - link City Rooms to Matrix rooms
|
||||||
|
|
||||||
|
-- Add Matrix room fields
|
||||||
|
ALTER TABLE city_rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS matrix_room_id TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS matrix_room_alias TEXT;
|
||||||
|
|
||||||
|
-- Create unique indexes (only for non-null values)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS city_rooms_matrix_room_id_uq
|
||||||
|
ON city_rooms (matrix_room_id)
|
||||||
|
WHERE matrix_room_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS city_rooms_matrix_room_alias_uq
|
||||||
|
ON city_rooms (matrix_room_alias)
|
||||||
|
WHERE matrix_room_alias IS NOT NULL;
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON COLUMN city_rooms.matrix_room_id IS 'Matrix room ID (e.g., !abc123:daarion.space)';
|
||||||
|
COMMENT ON COLUMN city_rooms.matrix_room_alias IS 'Matrix room alias (e.g., #city_general:daarion.space)';
|
||||||
|
|
||||||
24
services/city-service/Dockerfile
Normal file
24
services/city-service/Dockerfile
Normal 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 . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 7001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:7001/health')"
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7001"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
347
services/city-service/README.md
Normal file
347
services/city-service/README.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# 🏙️ DAARION City Service
|
||||||
|
|
||||||
|
**Версія:** 1.0.0
|
||||||
|
**Статус:** Development (Mock Data)
|
||||||
|
**Порт:** 7001
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Опис
|
||||||
|
|
||||||
|
City Service — це агрегатор даних для City Dashboard в екосистемі DAARION. Збирає та об'єднує інформацію з різних джерел для створення повного знімку стану міста.
|
||||||
|
|
||||||
|
### Функціонал
|
||||||
|
|
||||||
|
- 📊 **City Snapshot** — повний знімок стану міста
|
||||||
|
- 👤 **User Context** — профіль користувача та archetype
|
||||||
|
- 🏛️ **MicroDAO State** — стан microDAO користувача
|
||||||
|
- 📈 **Metrics Aggregation** — глобальні метрики міста
|
||||||
|
- 🖥️ **Node Status** — стан усіх нод
|
||||||
|
- 🤖 **Agent Presence** — активні агенти
|
||||||
|
- 🎯 **Quests** — активні квести
|
||||||
|
- 📡 **Events Feed** — останні події міста
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Швидкий старт
|
||||||
|
|
||||||
|
### Через Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# З кореня проєкту
|
||||||
|
./scripts/start-city-space-services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локально (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd services/city-service
|
||||||
|
|
||||||
|
# Створити віртуальне середовище
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# або venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Встановити залежності
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Запустити сервіс
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Або через uvicorn
|
||||||
|
uvicorn main:app --reload --port 7001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### **GET** `/health`
|
||||||
|
|
||||||
|
Health check endpoint
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "city-service"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **GET** `/api/city/snapshot`
|
||||||
|
|
||||||
|
Повертає повний знімок стану міста DAARION
|
||||||
|
|
||||||
|
**Response:** `CitySnapshot`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": "user:93",
|
||||||
|
"handle": "@alice:daarion.city",
|
||||||
|
"archetype": "Explorer",
|
||||||
|
"microdaoId": "microdao:7"
|
||||||
|
},
|
||||||
|
"microdao": {
|
||||||
|
"id": "microdao:7",
|
||||||
|
"name": "Quantum Garden",
|
||||||
|
"members": 7,
|
||||||
|
"humans": 4,
|
||||||
|
"agents": 3,
|
||||||
|
"balanceDcr": 12820,
|
||||||
|
"activity24h": 0.84
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"activityIndex": 0.71,
|
||||||
|
"avgAgentLatencyMs": 13,
|
||||||
|
"natsTps": 48200,
|
||||||
|
"nodeAvgLoad": 0.66,
|
||||||
|
"errorRate": 0.009,
|
||||||
|
"questEngagement": 0.62
|
||||||
|
},
|
||||||
|
"nodes": [...],
|
||||||
|
"agents": [...],
|
||||||
|
"quests": [...],
|
||||||
|
"events": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Схема агрегації даних
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ City Service │
|
||||||
|
│ (Port: 7001) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||||
|
│ Auth / │ │ microDAO │ │ Metrics │
|
||||||
|
│ Profile │ │ Service │ │ Collector │
|
||||||
|
│ Service │ │ │ │ │
|
||||||
|
└─────────────┘ └─────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────┤
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ NATS │ │ Redis / │
|
||||||
|
│ JetStream │ │ Timescale │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ NodeMetrics │ │ Agent │
|
||||||
|
│ Agent │ │ Registry │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Джерела даних
|
||||||
|
|
||||||
|
| Поле | Джерело | NATS Subject |
|
||||||
|
| ---------- | ----------------------------------------- | ------------------------- |
|
||||||
|
| `user` | Auth / Profile service | `user.profile.*` |
|
||||||
|
| `microdao` | microDAO service | `microdao.state.*` |
|
||||||
|
| `metrics` | Metrics collector (NATS → Redis/TSDB) | `metrics.city.*` |
|
||||||
|
| `nodes` | NodeMetrics Agent (NATS `node.metrics.*`) | `node.metrics.*` |
|
||||||
|
| `agents` | Agent Registry | `agent.status.*` |
|
||||||
|
| `quests` | Quest Engine | `quest.active.*` |
|
||||||
|
| `events` | JetStream Stream `events.city.*` | `events.city.*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Конфігурація
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Redis (для кешу метрик)
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# NATS (для підписки на події)
|
||||||
|
NATS_URL=nats://nats:4222
|
||||||
|
|
||||||
|
# PostgreSQL (для user/microDAO даних)
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/daarion
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:8899,https://daarion.city
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Структура проєкту
|
||||||
|
|
||||||
|
```
|
||||||
|
services/city-service/
|
||||||
|
├── main.py # FastAPI application
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker image
|
||||||
|
├── README.md # Ця документація
|
||||||
|
├── models/ # Pydantic models (TODO)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── city.py
|
||||||
|
│ └── response.py
|
||||||
|
├── services/ # Business logic (TODO)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── aggregator.py # Data aggregation
|
||||||
|
│ ├── nats_client.py # NATS integration
|
||||||
|
│ └── redis_client.py # Redis integration
|
||||||
|
└── tests/ # Unit tests (TODO)
|
||||||
|
├── __init__.py
|
||||||
|
└── test_api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Тестування
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:7001/health
|
||||||
|
|
||||||
|
# Get city snapshot
|
||||||
|
curl http://localhost:7001/api/city/snapshot
|
||||||
|
|
||||||
|
# Через API Gateway
|
||||||
|
curl http://localhost:8080/api/city/snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Response Time
|
||||||
|
|
||||||
|
- `/health`: < 10ms
|
||||||
|
- `/api/city/snapshot`: < 100ms (з кешем)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Моніторинг
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker health check
|
||||||
|
docker inspect daarion-city-service | grep Health
|
||||||
|
|
||||||
|
# Manual health check
|
||||||
|
curl -f http://localhost:7001/health || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow logs
|
||||||
|
docker logs -f daarion-city-service
|
||||||
|
|
||||||
|
# Last 100 lines
|
||||||
|
docker logs --tail 100 daarion-city-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics (TODO)
|
||||||
|
|
||||||
|
- Prometheus endpoint: `/metrics`
|
||||||
|
- Grafana dashboard: City Service Overview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### Service not starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker logs daarion-city-service
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
docker-compose -f docker-compose.city-space.yml up -d --build city-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check CORS_ORIGINS environment variable
|
||||||
|
docker exec daarion-city-service env | grep CORS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slow response times
|
||||||
|
|
||||||
|
- Перевір з'єднання з Redis
|
||||||
|
- Перевір з'єднання з NATS
|
||||||
|
- Переглянь логи для помилок агрегації
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Mock Data ✅
|
||||||
|
- [x] FastAPI application
|
||||||
|
- [x] Mock city snapshot
|
||||||
|
- [x] OpenAPI documentation
|
||||||
|
- [x] Docker setup
|
||||||
|
|
||||||
|
### Phase 2: Real Data Integration (Current)
|
||||||
|
- [ ] NATS client integration
|
||||||
|
- [ ] Redis client integration
|
||||||
|
- [ ] PostgreSQL integration
|
||||||
|
- [ ] Real-time metrics aggregation
|
||||||
|
- [ ] User profile integration
|
||||||
|
- [ ] MicroDAO state integration
|
||||||
|
|
||||||
|
### Phase 3: WebSocket Support
|
||||||
|
- [ ] `/ws/city` — real-time city updates
|
||||||
|
- [ ] `/ws/events` — event stream
|
||||||
|
- [ ] `/ws/metrics` — live metrics
|
||||||
|
|
||||||
|
### Phase 4: Optimization
|
||||||
|
- [ ] Response caching
|
||||||
|
- [ ] Query optimization
|
||||||
|
- [ ] Load testing
|
||||||
|
- [ ] Horizontal scaling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Документація
|
||||||
|
|
||||||
|
- **OpenAPI Docs:** http://localhost:7001/docs
|
||||||
|
- **ReDoc:** http://localhost:7001/redoc
|
||||||
|
- **OpenAPI JSON:** http://localhost:7001/openapi.json
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Створи feature branch
|
||||||
|
2. Додай тести
|
||||||
|
3. Оновити документацію
|
||||||
|
4. Створи PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Proprietary — DAARION Ecosystem
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Контакти
|
||||||
|
|
||||||
|
- **Maintainer:** DAARION Core Team
|
||||||
|
- **Issues:** GitHub Issues
|
||||||
|
- **Slack:** #city-service
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
0
services/city-service/common/__init__.py
Normal file
0
services/city-service/common/__init__.py
Normal file
107
services/city-service/common/redis_client.py
Normal file
107
services/city-service/common/redis_client.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Redis Client для DAARION
|
||||||
|
Використовується для Presence System та інших real-time features
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_redis_client: Optional[aioredis.Redis] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
"""
|
||||||
|
Отримати Redis клієнт (singleton)
|
||||||
|
"""
|
||||||
|
global _redis_client
|
||||||
|
|
||||||
|
if _redis_client is None:
|
||||||
|
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_redis_client = await aioredis.from_url(
|
||||||
|
redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
max_connections=10
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Redis connected: {redis_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Redis connection failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis():
|
||||||
|
"""
|
||||||
|
Закрити Redis connection
|
||||||
|
"""
|
||||||
|
global _redis_client
|
||||||
|
|
||||||
|
if _redis_client is not None:
|
||||||
|
await _redis_client.close()
|
||||||
|
_redis_client = None
|
||||||
|
logger.info("❌ Redis connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceRedis:
|
||||||
|
"""
|
||||||
|
Helper для роботи з Presence System в Redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
PREFIX = "presence:user:"
|
||||||
|
TTL = 40 # seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_online(user_id: str) -> None:
|
||||||
|
"""Встановити користувача онлайн"""
|
||||||
|
redis = await get_redis()
|
||||||
|
key = f"{PresenceRedis.PREFIX}{user_id}"
|
||||||
|
await redis.setex(key, PresenceRedis.TTL, "online")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def is_online(user_id: str) -> bool:
|
||||||
|
"""Перевірити чи користувач онлайн"""
|
||||||
|
redis = await get_redis()
|
||||||
|
key = f"{PresenceRedis.PREFIX}{user_id}"
|
||||||
|
value = await redis.get(key)
|
||||||
|
return value == "online"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_all_online() -> list[str]:
|
||||||
|
"""Отримати всіх онлайн користувачів"""
|
||||||
|
redis = await get_redis()
|
||||||
|
pattern = f"{PresenceRedis.PREFIX}*"
|
||||||
|
keys = []
|
||||||
|
|
||||||
|
async for key in redis.scan_iter(match=pattern, count=100):
|
||||||
|
user_id = key.replace(PresenceRedis.PREFIX, "")
|
||||||
|
keys.append(user_id)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_online_count() -> int:
|
||||||
|
"""Отримати кількість онлайн користувачів"""
|
||||||
|
users = await PresenceRedis.get_all_online()
|
||||||
|
return len(users)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def refresh_ttl(user_id: str) -> None:
|
||||||
|
"""Оновити TTL для користувача (heartbeat)"""
|
||||||
|
redis = await get_redis()
|
||||||
|
key = f"{PresenceRedis.PREFIX}{user_id}"
|
||||||
|
|
||||||
|
# Перевірити чи key існує
|
||||||
|
exists = await redis.exists(key)
|
||||||
|
if exists:
|
||||||
|
await redis.expire(key, PresenceRedis.TTL)
|
||||||
|
else:
|
||||||
|
# Якщо не існує — створити
|
||||||
|
await redis.setex(key, PresenceRedis.TTL, "online")
|
||||||
|
|
||||||
351
services/city-service/main.py
Normal file
351
services/city-service/main.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
DAARION City Service
|
||||||
|
|
||||||
|
Агрегатор даних для City Dashboard + City Rooms + Presence
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Import new modules
|
||||||
|
import routes_city
|
||||||
|
import ws_city
|
||||||
|
import repo_city
|
||||||
|
from common.redis_client import get_redis, close_redis
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="DAARION City Service",
|
||||||
|
version="2.0.0",
|
||||||
|
description="City snapshot aggregator + Rooms + Presence for DAARION ecosystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # TODO: обмежити в production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(routes_city.router)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Models
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CityUser(BaseModel):
|
||||||
|
id: str
|
||||||
|
handle: str
|
||||||
|
archetype: str
|
||||||
|
microdaoId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CityMicroDAO(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
members: int
|
||||||
|
humans: int
|
||||||
|
agents: int
|
||||||
|
balanceDcr: float
|
||||||
|
activity24h: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CityMetrics(BaseModel):
|
||||||
|
activityIndex: float = Field(ge=0, le=1)
|
||||||
|
avgAgentLatencyMs: float
|
||||||
|
natsTps: int
|
||||||
|
nodeAvgLoad: float = Field(ge=0, le=1)
|
||||||
|
errorRate: float
|
||||||
|
questEngagement: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CityNode(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
gpuLoad: float = Field(ge=0, le=1)
|
||||||
|
latencyMs: float
|
||||||
|
agents: int
|
||||||
|
status: str = Field(pattern="^(healthy|warn|critical)$")
|
||||||
|
|
||||||
|
|
||||||
|
class CityAgentSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
status: str = Field(pattern="^(online|offline|busy)$")
|
||||||
|
lastAction: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CityQuestSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
progress: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CityEvent(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str = Field(pattern="^(dao|node|matrix|quest|system)$")
|
||||||
|
label: str
|
||||||
|
timestamp: str
|
||||||
|
severity: str = Field(pattern="^(info|warn|error)$")
|
||||||
|
|
||||||
|
|
||||||
|
class CitySnapshot(BaseModel):
|
||||||
|
user: CityUser
|
||||||
|
microdao: Optional[CityMicroDAO]
|
||||||
|
metrics: CityMetrics
|
||||||
|
nodes: List[CityNode]
|
||||||
|
agents: List[CityAgentSummary]
|
||||||
|
quests: List[CityQuestSummary]
|
||||||
|
events: List[CityEvent]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Mock Data (тимчасово, до інтеграції з реальними джерелами)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
MOCK_CITY_SNAPSHOT = CitySnapshot(
|
||||||
|
user=CityUser(
|
||||||
|
id="user:93",
|
||||||
|
handle="@alice:daarion.city",
|
||||||
|
archetype="Explorer",
|
||||||
|
microdaoId="microdao:7"
|
||||||
|
),
|
||||||
|
microdao=CityMicroDAO(
|
||||||
|
id="microdao:7",
|
||||||
|
name="Quantum Garden",
|
||||||
|
members=7,
|
||||||
|
humans=4,
|
||||||
|
agents=3,
|
||||||
|
balanceDcr=12820,
|
||||||
|
activity24h=0.84
|
||||||
|
),
|
||||||
|
metrics=CityMetrics(
|
||||||
|
activityIndex=0.71,
|
||||||
|
avgAgentLatencyMs=13,
|
||||||
|
natsTps=48200,
|
||||||
|
nodeAvgLoad=0.66,
|
||||||
|
errorRate=0.009,
|
||||||
|
questEngagement=0.62
|
||||||
|
),
|
||||||
|
nodes=[
|
||||||
|
CityNode(
|
||||||
|
id="node:03",
|
||||||
|
label="Quantum Relay",
|
||||||
|
gpuLoad=0.72,
|
||||||
|
latencyMs=14,
|
||||||
|
agents=14,
|
||||||
|
status="healthy"
|
||||||
|
),
|
||||||
|
CityNode(
|
||||||
|
id="node:04",
|
||||||
|
label="Atlas Engine",
|
||||||
|
gpuLoad=0.88,
|
||||||
|
latencyMs=19,
|
||||||
|
agents=11,
|
||||||
|
status="warn"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
agents=[
|
||||||
|
CityAgentSummary(
|
||||||
|
id="agent:sofia",
|
||||||
|
name="Sofia-Prime",
|
||||||
|
role="System Architect",
|
||||||
|
status="online",
|
||||||
|
lastAction="Summarized DAO events 2m ago"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
quests=[
|
||||||
|
CityQuestSummary(id="q1", label="Visit Space Map", progress=0.4),
|
||||||
|
CityQuestSummary(id="q2", label="Vote in DAO proposal", progress=0.0),
|
||||||
|
],
|
||||||
|
events=[
|
||||||
|
CityEvent(
|
||||||
|
id="evt-1133",
|
||||||
|
type="dao",
|
||||||
|
label="New proposal in Aurora Circle",
|
||||||
|
timestamp="2025-11-24T09:12:11Z",
|
||||||
|
severity="info"
|
||||||
|
),
|
||||||
|
CityEvent(
|
||||||
|
id="evt-1134",
|
||||||
|
type="node",
|
||||||
|
label="NODE-03 GPU spike",
|
||||||
|
timestamp="2025-11-24T09:12:14Z",
|
||||||
|
severity="warn"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# API Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {"status": "healthy", "service": "city-service"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/city/snapshot", response_model=CitySnapshot)
|
||||||
|
async def get_city_snapshot():
|
||||||
|
"""
|
||||||
|
Повертає повний знімок стану міста DAARION
|
||||||
|
|
||||||
|
Агрегує дані з:
|
||||||
|
- Auth / Profile service
|
||||||
|
- microDAO service
|
||||||
|
- Metrics collector (NATS → Redis/TSDB)
|
||||||
|
- NodeMetrics Agent (NATS node.metrics.*)
|
||||||
|
- Agent Registry
|
||||||
|
- Quest Engine
|
||||||
|
- JetStream Stream events.city.*
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: замінити на реальну агрегацію даних
|
||||||
|
logger.info("Fetching city snapshot")
|
||||||
|
return MOCK_CITY_SNAPSHOT
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching city snapshot: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to fetch city snapshot")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebSocket Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from websocket import (
|
||||||
|
manager,
|
||||||
|
city_updates_generator,
|
||||||
|
events_stream_generator,
|
||||||
|
metrics_stream_generator,
|
||||||
|
agents_presence_generator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/city")
|
||||||
|
async def websocket_city(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket для live оновлень City Dashboard
|
||||||
|
|
||||||
|
Надсилає оновлення метрик, нод, агентів кожні 5 секунд
|
||||||
|
"""
|
||||||
|
await manager.connect(websocket, "city")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep connection alive
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/events")
|
||||||
|
async def websocket_events(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket для потоку подій міста
|
||||||
|
|
||||||
|
Надсилає нові події в реальному часі
|
||||||
|
"""
|
||||||
|
await manager.connect(websocket, "events")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/metrics")
|
||||||
|
async def websocket_metrics(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket для live метрик
|
||||||
|
|
||||||
|
Надсилає оновлення метрик кожну секунду
|
||||||
|
"""
|
||||||
|
await manager.connect(websocket, "metrics")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/agents")
|
||||||
|
async def websocket_agents(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket для присутності агентів
|
||||||
|
|
||||||
|
Надсилає оновлення присутності агентів
|
||||||
|
"""
|
||||||
|
await manager.connect(websocket, "agents")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/city/rooms/{room_id}")
|
||||||
|
async def websocket_room_endpoint(websocket: WebSocket, room_id: str):
|
||||||
|
"""WebSocket для City Room"""
|
||||||
|
await ws_city.websocket_city_room(websocket, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/city/presence")
|
||||||
|
async def websocket_presence_endpoint(websocket: WebSocket):
|
||||||
|
"""WebSocket для Presence System"""
|
||||||
|
await ws_city.websocket_city_presence(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Запустити background tasks для WebSocket оновлень"""
|
||||||
|
logger.info("🚀 City Service starting...")
|
||||||
|
|
||||||
|
# Initialize Redis
|
||||||
|
try:
|
||||||
|
await get_redis()
|
||||||
|
logger.info("✅ Redis connection established")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Redis connection failed: {e}")
|
||||||
|
|
||||||
|
# Background tasks
|
||||||
|
asyncio.create_task(city_updates_generator())
|
||||||
|
asyncio.create_task(events_stream_generator())
|
||||||
|
asyncio.create_task(metrics_stream_generator())
|
||||||
|
asyncio.create_task(agents_presence_generator())
|
||||||
|
asyncio.create_task(ws_city.presence_cleanup_task())
|
||||||
|
|
||||||
|
logger.info("✅ WebSocket background tasks started")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
"""Cleanup при зупинці"""
|
||||||
|
logger.info("🛑 City Service shutting down...")
|
||||||
|
await repo_city.close_pool()
|
||||||
|
await close_redis()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=7001)
|
||||||
|
|
||||||
84
services/city-service/matrix_client.py
Normal file
84
services/city-service/matrix_client.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Matrix Gateway Client for City Service
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_matrix_room(slug: str, name: str, visibility: str = "public") -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Create a Matrix room via Matrix Gateway.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (matrix_room_id, matrix_room_alias) or (None, None) on failure
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"{MATRIX_GATEWAY_URL}/internal/matrix/rooms/create",
|
||||||
|
json={
|
||||||
|
"slug": slug,
|
||||||
|
"name": name,
|
||||||
|
"visibility": visibility
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Matrix room created: {data['matrix_room_id']}")
|
||||||
|
return data["matrix_room_id"], data["matrix_room_alias"]
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create Matrix room: {response.text}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Matrix Gateway request error: {e}")
|
||||||
|
return None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Matrix room creation error: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def find_matrix_room_by_alias(alias: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Find a Matrix room by alias via Matrix Gateway.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (matrix_room_id, matrix_room_alias) or (None, None) if not found
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{MATRIX_GATEWAY_URL}/internal/matrix/rooms/find-by-alias",
|
||||||
|
params={"alias": alias}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data["matrix_room_id"], data["matrix_room_alias"]
|
||||||
|
elif response.status_code == 404:
|
||||||
|
return None, None
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to find Matrix room: {response.text}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Matrix Gateway request error: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_matrix_gateway_health() -> bool:
|
||||||
|
"""Check if Matrix Gateway is available."""
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(f"{MATRIX_GATEWAY_URL}/healthz")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
109
services/city-service/models_city.py
Normal file
109
services/city-service/models_city.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Models для City Backend
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Rooms
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CityRoomBase(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CityRoomCreate(CityRoomBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CityRoomRead(CityRoomBase):
|
||||||
|
id: str
|
||||||
|
is_default: bool
|
||||||
|
created_at: datetime
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
members_online: int = 0
|
||||||
|
last_event: Optional[str] = None
|
||||||
|
# Matrix integration
|
||||||
|
matrix_room_id: Optional[str] = None
|
||||||
|
matrix_room_alias: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Room Messages
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CityRoomMessageBase(BaseModel):
|
||||||
|
body: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class CityRoomMessageCreate(CityRoomMessageBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CityRoomMessageRead(CityRoomMessageBase):
|
||||||
|
id: str
|
||||||
|
room_id: str
|
||||||
|
author_user_id: Optional[str] = None
|
||||||
|
author_agent_id: Optional[str] = None
|
||||||
|
username: Optional[str] = "Anonymous" # Для frontend
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Room Detail (з повідомленнями)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CityRoomDetail(CityRoomRead):
|
||||||
|
messages: List[CityRoomMessageRead] = []
|
||||||
|
online_members: List[str] = [] # user_ids
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Feed Events
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CityFeedEventRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: str # 'room_message', 'agent_reply', 'system', 'dao_event'
|
||||||
|
room_id: Optional[str] = None
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
agent_id: Optional[str] = None
|
||||||
|
payload: dict
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Presence
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class PresenceUpdate(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
status: str # 'online', 'offline', 'away'
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceBulkUpdate(BaseModel):
|
||||||
|
users: List[PresenceUpdate]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WebSocket Messages
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class WSRoomMessage(BaseModel):
|
||||||
|
event: str # 'room.message', 'room.join', 'room.leave'
|
||||||
|
room_id: Optional[str] = None
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
message: Optional[CityRoomMessageRead] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WSPresenceMessage(BaseModel):
|
||||||
|
event: str # 'presence.heartbeat', 'presence.update'
|
||||||
|
user_id: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
228
services/city-service/repo_city.py
Normal file
228
services/city-service/repo_city.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Repository для City Backend (PostgreSQL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncpg
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool() -> asyncpg.Pool:
|
||||||
|
"""Отримати connection pool"""
|
||||||
|
global _pool
|
||||||
|
|
||||||
|
if _pool is None:
|
||||||
|
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
|
||||||
|
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||||
|
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool():
|
||||||
|
"""Закрити connection pool"""
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id(prefix: str) -> str:
|
||||||
|
"""Генерувати простий ID"""
|
||||||
|
return f"{prefix}_{secrets.token_urlsafe(12)}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Rooms Repository
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_all_rooms(limit: int = 100, offset: int = 0) -> List[dict]:
|
||||||
|
"""Отримати всі кімнати"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, slug, name, description, is_default, created_at, created_by,
|
||||||
|
matrix_room_id, matrix_room_alias
|
||||||
|
FROM city_rooms
|
||||||
|
ORDER BY is_default DESC, created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = await pool.fetch(query, limit, offset)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_room_by_id(room_id: str) -> Optional[dict]:
|
||||||
|
"""Отримати кімнату по ID"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, slug, name, description, is_default, created_at, created_by,
|
||||||
|
matrix_room_id, matrix_room_alias
|
||||||
|
FROM city_rooms
|
||||||
|
WHERE id = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, room_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_room_by_slug(slug: str) -> Optional[dict]:
|
||||||
|
"""Отримати кімнату по slug"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, slug, name, description, is_default, created_at, created_by,
|
||||||
|
matrix_room_id, matrix_room_alias
|
||||||
|
FROM city_rooms
|
||||||
|
WHERE slug = $1
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, slug)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_room(
|
||||||
|
slug: str,
|
||||||
|
name: str,
|
||||||
|
description: Optional[str],
|
||||||
|
created_by: Optional[str],
|
||||||
|
matrix_room_id: Optional[str] = None,
|
||||||
|
matrix_room_alias: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""Створити кімнату"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
room_id = f"room_city_{slug}"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO city_rooms (id, slug, name, description, created_by, matrix_room_id, matrix_room_alias)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, slug, name, description, is_default, created_at, created_by, matrix_room_id, matrix_room_alias
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, room_id, slug, name, description, created_by, matrix_room_id, matrix_room_alias)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_room_matrix(room_id: str, matrix_room_id: str, matrix_room_alias: str) -> Optional[dict]:
|
||||||
|
"""Оновити Matrix поля кімнати"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE city_rooms
|
||||||
|
SET matrix_room_id = $2, matrix_room_alias = $3
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, slug, name, description, is_default, created_at, created_by, matrix_room_id, matrix_room_alias
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, room_id, matrix_room_id, matrix_room_alias)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_rooms_without_matrix() -> List[dict]:
|
||||||
|
"""Отримати кімнати без Matrix інтеграції"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, slug, name, description, is_default, created_at, created_by,
|
||||||
|
matrix_room_id, matrix_room_alias
|
||||||
|
FROM city_rooms
|
||||||
|
WHERE matrix_room_id IS NULL
|
||||||
|
ORDER BY created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = await pool.fetch(query)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Room Messages Repository
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_room_messages(room_id: str, limit: int = 50) -> List[dict]:
|
||||||
|
"""Отримати повідомлення кімнати"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, room_id, author_user_id, author_agent_id, body, created_at
|
||||||
|
FROM city_room_messages
|
||||||
|
WHERE room_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = await pool.fetch(query, room_id, limit)
|
||||||
|
# Reverse для правильного порядку (старі → нові)
|
||||||
|
return [dict(row) for row in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_room_message(
|
||||||
|
room_id: str,
|
||||||
|
body: str,
|
||||||
|
author_user_id: Optional[str] = None,
|
||||||
|
author_agent_id: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""Створити повідомлення в кімнаті"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
message_id = generate_id("m_city")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO city_room_messages (id, room_id, author_user_id, author_agent_id, body)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, room_id, author_user_id, author_agent_id, body, created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, message_id, room_id, author_user_id, author_agent_id, body)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Feed Events Repository
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_feed_events(limit: int = 20, offset: int = 0) -> List[dict]:
|
||||||
|
"""Отримати події feed"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, kind, room_id, user_id, agent_id, payload, created_at
|
||||||
|
FROM city_feed_events
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = await pool.fetch(query, limit, offset)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_feed_event(
|
||||||
|
kind: str,
|
||||||
|
payload: dict,
|
||||||
|
room_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
agent_id: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""Створити подію в feed"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
event_id = generate_id("evt_city")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO city_feed_events (id, kind, room_id, user_id, agent_id, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
|
RETURNING id, kind, room_id, user_id, agent_id, payload, created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
|
row = await pool.fetchrow(query, event_id, kind, room_id, user_id, agent_id, payload_json)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
7
services/city-service/requirements.txt
Normal file
7
services/city-service/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
asyncpg==0.29.0
|
||||||
|
redis==5.0.1
|
||||||
|
websockets==12.0
|
||||||
|
requests==2.31.0
|
||||||
311
services/city-service/routes_city.py
Normal file
311
services/city-service/routes_city.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
City Backend API Routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from models_city import (
|
||||||
|
CityRoomRead,
|
||||||
|
CityRoomCreate,
|
||||||
|
CityRoomDetail,
|
||||||
|
CityRoomMessageRead,
|
||||||
|
CityRoomMessageCreate,
|
||||||
|
CityFeedEventRead
|
||||||
|
)
|
||||||
|
import repo_city
|
||||||
|
from common.redis_client import PresenceRedis, get_redis
|
||||||
|
from matrix_client import create_matrix_room, find_matrix_room_by_alias
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/city", tags=["city"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Rooms API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/rooms", response_model=List[CityRoomRead])
|
||||||
|
async def get_city_rooms(limit: int = 100, offset: int = 0):
|
||||||
|
"""
|
||||||
|
Отримати список всіх City Rooms
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rooms = await repo_city.get_all_rooms(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
# Додати online count (приблизно)
|
||||||
|
online_count = await PresenceRedis.get_online_count()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for room in rooms:
|
||||||
|
result.append({
|
||||||
|
**room,
|
||||||
|
"members_online": online_count if room.get("is_default") else max(1, online_count // 2),
|
||||||
|
"last_event": None # TODO: з останнього повідомлення
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get city rooms: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get city rooms")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms", response_model=CityRoomRead)
|
||||||
|
async def create_city_room(payload: CityRoomCreate):
|
||||||
|
"""
|
||||||
|
Створити нову City Room (автоматично створює Matrix room)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: витягнути user_id з JWT
|
||||||
|
created_by = "u_system" # Mock для MVP
|
||||||
|
|
||||||
|
# Перевірити чи не існує вже
|
||||||
|
existing = await repo_city.get_room_by_slug(payload.slug)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Room with this slug already exists")
|
||||||
|
|
||||||
|
# Створити Matrix room
|
||||||
|
matrix_room_id, matrix_room_alias = await create_matrix_room(
|
||||||
|
slug=payload.slug,
|
||||||
|
name=payload.name,
|
||||||
|
visibility="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matrix_room_id:
|
||||||
|
logger.warning(f"Failed to create Matrix room for {payload.slug}, proceeding without Matrix")
|
||||||
|
|
||||||
|
room = await repo_city.create_room(
|
||||||
|
slug=payload.slug,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
created_by=created_by,
|
||||||
|
matrix_room_id=matrix_room_id,
|
||||||
|
matrix_room_alias=matrix_room_alias
|
||||||
|
)
|
||||||
|
|
||||||
|
# Додати початкове повідомлення
|
||||||
|
await repo_city.create_room_message(
|
||||||
|
room_id=room["id"],
|
||||||
|
body=f"Кімната '{payload.name}' створена! Ласкаво просимо! 🎉",
|
||||||
|
author_agent_id="ag_system"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Додати в feed
|
||||||
|
await repo_city.create_feed_event(
|
||||||
|
kind="system",
|
||||||
|
room_id=room["id"],
|
||||||
|
payload={"action": "room_created", "room_name": payload.name, "matrix_room_id": matrix_room_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {**room, "members_online": 1, "last_event": None}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create city room: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create city room")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rooms/{room_id}", response_model=CityRoomDetail)
|
||||||
|
async def get_city_room(room_id: str):
|
||||||
|
"""
|
||||||
|
Отримати деталі City Room з повідомленнями
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
room = await repo_city.get_room_by_id(room_id)
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
|
||||||
|
messages = await repo_city.get_room_messages(room_id, limit=50)
|
||||||
|
|
||||||
|
# Додати username до повідомлень
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("author_user_id"):
|
||||||
|
msg["username"] = f"User-{msg['author_user_id'][-4:]}" # Mock
|
||||||
|
elif msg.get("author_agent_id"):
|
||||||
|
msg["username"] = "System Agent"
|
||||||
|
else:
|
||||||
|
msg["username"] = "Anonymous"
|
||||||
|
|
||||||
|
online_users = await PresenceRedis.get_all_online()
|
||||||
|
|
||||||
|
return {
|
||||||
|
**room,
|
||||||
|
"members_online": len(online_users),
|
||||||
|
"last_event": None,
|
||||||
|
"messages": messages,
|
||||||
|
"online_members": online_users[:20] # Перші 20
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get city room: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get city room")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms/{room_id}/messages", response_model=CityRoomMessageRead)
|
||||||
|
async def send_city_room_message(room_id: str, payload: CityRoomMessageCreate):
|
||||||
|
"""
|
||||||
|
Надіслати повідомлення в City Room
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Перевірити чи кімната існує
|
||||||
|
room = await repo_city.get_room_by_id(room_id)
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
|
||||||
|
# TODO: витягнути user_id з JWT
|
||||||
|
author_user_id = "u_mock_user" # Mock для MVP
|
||||||
|
|
||||||
|
# Створити повідомлення
|
||||||
|
message = await repo_city.create_room_message(
|
||||||
|
room_id=room_id,
|
||||||
|
body=payload.body,
|
||||||
|
author_user_id=author_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Додати в feed
|
||||||
|
await repo_city.create_feed_event(
|
||||||
|
kind="room_message",
|
||||||
|
room_id=room_id,
|
||||||
|
user_id=author_user_id,
|
||||||
|
payload={"body": payload.body[:100], "message_id": message["id"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Broadcast WS event
|
||||||
|
# await ws_manager.broadcast_to_room(room_id, {
|
||||||
|
# "event": "room.message",
|
||||||
|
# "message": message
|
||||||
|
# })
|
||||||
|
|
||||||
|
# Додати username
|
||||||
|
message["username"] = f"User-{author_user_id[-4:]}"
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send room message: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to send message")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms/{room_id}/join")
|
||||||
|
async def join_city_room(room_id: str):
|
||||||
|
"""
|
||||||
|
Приєднатися до City Room (для tracking)
|
||||||
|
"""
|
||||||
|
# TODO: витягнути user_id з JWT
|
||||||
|
user_id = "u_mock_user"
|
||||||
|
|
||||||
|
# Для MVP просто повертаємо success
|
||||||
|
# У production можна зберігати active memberships в Redis
|
||||||
|
|
||||||
|
logger.info(f"User {user_id} joined room {room_id}")
|
||||||
|
return {"status": "joined", "room_id": room_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms/{room_id}/leave")
|
||||||
|
async def leave_city_room(room_id: str):
|
||||||
|
"""
|
||||||
|
Покинути City Room
|
||||||
|
"""
|
||||||
|
# TODO: витягнути user_id з JWT
|
||||||
|
user_id = "u_mock_user"
|
||||||
|
|
||||||
|
logger.info(f"User {user_id} left room {room_id}")
|
||||||
|
return {"status": "left", "room_id": room_id}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Matrix Backfill API (Internal)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/matrix/backfill")
|
||||||
|
async def backfill_matrix_rooms():
|
||||||
|
"""
|
||||||
|
Backfill Matrix rooms for existing City Rooms that don't have Matrix integration.
|
||||||
|
This is an internal endpoint for admin use.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rooms_without_matrix = await repo_city.get_rooms_without_matrix()
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"processed": 0,
|
||||||
|
"created": 0,
|
||||||
|
"found": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for room in rooms_without_matrix:
|
||||||
|
results["processed"] += 1
|
||||||
|
slug = room["slug"]
|
||||||
|
name = room["name"]
|
||||||
|
room_id = room["id"]
|
||||||
|
|
||||||
|
# Спочатку спробувати знайти існуючу Matrix room
|
||||||
|
alias = f"#city_{slug}:daarion.space"
|
||||||
|
matrix_room_id, matrix_room_alias = await find_matrix_room_by_alias(alias)
|
||||||
|
|
||||||
|
if matrix_room_id:
|
||||||
|
# Знайдено існуючу
|
||||||
|
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||||||
|
results["found"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"room_id": room_id,
|
||||||
|
"slug": slug,
|
||||||
|
"status": "found",
|
||||||
|
"matrix_room_id": matrix_room_id
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Створити нову
|
||||||
|
matrix_room_id, matrix_room_alias = await create_matrix_room(slug, name, "public")
|
||||||
|
|
||||||
|
if matrix_room_id:
|
||||||
|
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||||||
|
results["created"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"room_id": room_id,
|
||||||
|
"slug": slug,
|
||||||
|
"status": "created",
|
||||||
|
"matrix_room_id": matrix_room_id
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results["failed"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"room_id": room_id,
|
||||||
|
"slug": slug,
|
||||||
|
"status": "failed",
|
||||||
|
"error": "Could not create Matrix room"
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Matrix backfill completed: {results['processed']} processed, "
|
||||||
|
f"{results['created']} created, {results['found']} found, {results['failed']} failed")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Matrix backfill failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# City Feed API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/feed", response_model=List[CityFeedEventRead])
|
||||||
|
async def get_city_feed(limit: int = 20, offset: int = 0):
|
||||||
|
"""
|
||||||
|
Отримати City Feed (останні події)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
events = await repo_city.get_feed_events(limit=limit, offset=offset)
|
||||||
|
return events
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get city feed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get city feed")
|
||||||
|
|
||||||
162
services/city-service/websocket.py
Normal file
162
services/city-service/websocket.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
WebSocket Support for City Service
|
||||||
|
|
||||||
|
Real-time updates для City Dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""Менеджер WebSocket з'єднань"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: List[WebSocket] = []
|
||||||
|
self.subscriptions: Dict[str, List[WebSocket]] = {
|
||||||
|
"city": [],
|
||||||
|
"events": [],
|
||||||
|
"metrics": [],
|
||||||
|
"agents": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket, channel: str = "city"):
|
||||||
|
"""Підключити WebSocket"""
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.append(websocket)
|
||||||
|
if channel in self.subscriptions:
|
||||||
|
self.subscriptions[channel].append(websocket)
|
||||||
|
logger.info(f"Client connected to channel: {channel}")
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
"""Від'єднати WebSocket"""
|
||||||
|
self.active_connections.remove(websocket)
|
||||||
|
for channel in self.subscriptions.values():
|
||||||
|
if websocket in channel:
|
||||||
|
channel.remove(websocket)
|
||||||
|
logger.info("Client disconnected")
|
||||||
|
|
||||||
|
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||||
|
"""Надіслати повідомлення конкретному клієнту"""
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
async def broadcast(self, message: str, channel: str = "city"):
|
||||||
|
"""Надіслати повідомлення всім клієнтам каналу"""
|
||||||
|
if channel in self.subscriptions:
|
||||||
|
for connection in self.subscriptions[channel]:
|
||||||
|
try:
|
||||||
|
await connection.send_text(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error broadcasting to client: {e}")
|
||||||
|
|
||||||
|
async def broadcast_json(self, data: Dict[str, Any], channel: str = "city"):
|
||||||
|
"""Надіслати JSON всім клієнтам каналу"""
|
||||||
|
message = json.dumps(data)
|
||||||
|
await self.broadcast(message, channel)
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальний instance
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
async def city_updates_generator():
|
||||||
|
"""
|
||||||
|
Генератор оновлень для City Dashboard
|
||||||
|
|
||||||
|
TODO: Підключити до реальних джерел (NATS, Redis)
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5) # Оновлення кожні 5 секунд
|
||||||
|
|
||||||
|
# Mock update
|
||||||
|
update = {
|
||||||
|
"type": "city_update",
|
||||||
|
"timestamp": "2025-11-24T10:00:00Z",
|
||||||
|
"data": {
|
||||||
|
"metrics": {
|
||||||
|
"activityIndex": 0.72,
|
||||||
|
"nodeAvgLoad": 0.65,
|
||||||
|
},
|
||||||
|
"nodes_online": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.broadcast_json(update, "city")
|
||||||
|
|
||||||
|
|
||||||
|
async def events_stream_generator():
|
||||||
|
"""
|
||||||
|
Генератор потоку подій
|
||||||
|
|
||||||
|
TODO: Підключити до NATS JetStream events.city.*
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3) # Нові події кожні 3 секунди
|
||||||
|
|
||||||
|
# Mock event
|
||||||
|
event = {
|
||||||
|
"type": "city_event",
|
||||||
|
"timestamp": "2025-11-24T10:00:00Z",
|
||||||
|
"event": {
|
||||||
|
"id": f"evt-{asyncio.get_event_loop().time()}",
|
||||||
|
"type": "node",
|
||||||
|
"label": "Mock event for testing",
|
||||||
|
"severity": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.broadcast_json(event, "events")
|
||||||
|
|
||||||
|
|
||||||
|
async def metrics_stream_generator():
|
||||||
|
"""
|
||||||
|
Генератор live метрик
|
||||||
|
|
||||||
|
TODO: Підключити до Redis/Prometheus
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1) # Метрики кожну секунду
|
||||||
|
|
||||||
|
# Mock metrics
|
||||||
|
metrics = {
|
||||||
|
"type": "metrics_update",
|
||||||
|
"timestamp": "2025-11-24T10:00:00Z",
|
||||||
|
"metrics": {
|
||||||
|
"activityIndex": 0.71 + (asyncio.get_event_loop().time() % 10) / 100,
|
||||||
|
"natsTps": int(48000 + (asyncio.get_event_loop().time() % 1000)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.broadcast_json(metrics, "metrics")
|
||||||
|
|
||||||
|
|
||||||
|
async def agents_presence_generator():
|
||||||
|
"""
|
||||||
|
Генератор присутності агентів
|
||||||
|
|
||||||
|
TODO: Підключити до Agent Registry
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(10) # Оновлення присутності кожні 10 секунд
|
||||||
|
|
||||||
|
# Mock agent presence
|
||||||
|
presence = {
|
||||||
|
"type": "agent_presence",
|
||||||
|
"timestamp": "2025-11-24T10:00:00Z",
|
||||||
|
"agents": {
|
||||||
|
"online": 42,
|
||||||
|
"offline": 3,
|
||||||
|
"busy": 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.broadcast_json(presence, "agents")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
222
services/city-service/ws_city.py
Normal file
222
services/city-service/ws_city.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
WebSocket Endpoints для City Backend
|
||||||
|
Rooms + Presence System
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
from typing import Dict, Set, Optional
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from common.redis_client import PresenceRedis
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WebSocket Connection Manager
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CityWSManager:
|
||||||
|
"""Менеджер WebSocket підключень для City"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# room_id -> set of websockets
|
||||||
|
self.room_connections: Dict[str, Set[WebSocket]] = {}
|
||||||
|
# presence connections
|
||||||
|
self.presence_connections: Set[WebSocket] = set()
|
||||||
|
|
||||||
|
async def connect_to_room(self, websocket: WebSocket, room_id: str):
|
||||||
|
"""Підключити клієнта до кімнати"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
if room_id not in self.room_connections:
|
||||||
|
self.room_connections[room_id] = set()
|
||||||
|
|
||||||
|
self.room_connections[room_id].add(websocket)
|
||||||
|
logger.info(f"✅ Client connected to room {room_id}. Total: {len(self.room_connections[room_id])}")
|
||||||
|
|
||||||
|
def disconnect_from_room(self, websocket: WebSocket, room_id: str):
|
||||||
|
"""Від'єднати клієнта від кімнати"""
|
||||||
|
if room_id in self.room_connections:
|
||||||
|
self.room_connections[room_id].discard(websocket)
|
||||||
|
|
||||||
|
if len(self.room_connections[room_id]) == 0:
|
||||||
|
del self.room_connections[room_id]
|
||||||
|
|
||||||
|
logger.info(f"❌ Client disconnected from room {room_id}")
|
||||||
|
|
||||||
|
async def broadcast_to_room(self, room_id: str, message: dict):
|
||||||
|
"""Broadcast повідомлення всім клієнтам кімнати"""
|
||||||
|
if room_id not in self.room_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
|
||||||
|
for websocket in self.room_connections[room_id]:
|
||||||
|
try:
|
||||||
|
await websocket.send_json(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send to websocket: {e}")
|
||||||
|
disconnected.add(websocket)
|
||||||
|
|
||||||
|
# Видалити disconnected
|
||||||
|
for ws in disconnected:
|
||||||
|
self.room_connections[room_id].discard(ws)
|
||||||
|
|
||||||
|
# Presence methods
|
||||||
|
|
||||||
|
async def connect_to_presence(self, websocket: WebSocket):
|
||||||
|
"""Підключити клієнта до Presence System"""
|
||||||
|
await websocket.accept()
|
||||||
|
self.presence_connections.add(websocket)
|
||||||
|
logger.info(f"✅ Client connected to presence. Total: {len(self.presence_connections)}")
|
||||||
|
|
||||||
|
def disconnect_from_presence(self, websocket: WebSocket):
|
||||||
|
"""Від'єднати клієнта від Presence System"""
|
||||||
|
self.presence_connections.discard(websocket)
|
||||||
|
logger.info(f"❌ Client disconnected from presence")
|
||||||
|
|
||||||
|
async def broadcast_presence_update(self, message: dict):
|
||||||
|
"""Broadcast presence update всім клієнтам"""
|
||||||
|
disconnected = set()
|
||||||
|
|
||||||
|
for websocket in self.presence_connections:
|
||||||
|
try:
|
||||||
|
await websocket.send_json(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send presence update: {e}")
|
||||||
|
disconnected.add(websocket)
|
||||||
|
|
||||||
|
# Видалити disconnected
|
||||||
|
for ws in disconnected:
|
||||||
|
self.presence_connections.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
# Global manager instance
|
||||||
|
ws_manager = CityWSManager()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WebSocket Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def websocket_city_room(websocket: WebSocket, room_id: str):
|
||||||
|
"""
|
||||||
|
WebSocket для City Room
|
||||||
|
/ws/city/rooms/{room_id}
|
||||||
|
"""
|
||||||
|
await ws_manager.connect_to_room(websocket, room_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = json.loads(data)
|
||||||
|
event = message.get("event")
|
||||||
|
|
||||||
|
if event == "room.join":
|
||||||
|
# User joined room
|
||||||
|
user_id = message.get("user_id", "anonymous")
|
||||||
|
await ws_manager.broadcast_to_room(room_id, {
|
||||||
|
"event": "room.join",
|
||||||
|
"room_id": room_id,
|
||||||
|
"user_id": user_id
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event == "room.leave":
|
||||||
|
# User left room
|
||||||
|
user_id = message.get("user_id", "anonymous")
|
||||||
|
await ws_manager.broadcast_to_room(room_id, {
|
||||||
|
"event": "room.leave",
|
||||||
|
"room_id": room_id,
|
||||||
|
"user_id": user_id
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event == "room.message.send":
|
||||||
|
# New message (але краще через HTTP API)
|
||||||
|
logger.info(f"Message via WS (should use HTTP): {message}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown event: {event}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Invalid JSON from client")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
ws_manager.disconnect_from_room(websocket, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_city_presence(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket для Presence System
|
||||||
|
/ws/city/presence
|
||||||
|
"""
|
||||||
|
await ws_manager.connect_to_presence(websocket)
|
||||||
|
|
||||||
|
current_user_id: Optional[str] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = json.loads(data)
|
||||||
|
event = message.get("event")
|
||||||
|
|
||||||
|
if event == "presence.heartbeat":
|
||||||
|
user_id = message.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_user_id = user_id
|
||||||
|
|
||||||
|
# Оновити Redis
|
||||||
|
await PresenceRedis.set_online(user_id)
|
||||||
|
|
||||||
|
# Broadcast presence update
|
||||||
|
await ws_manager.broadcast_presence_update({
|
||||||
|
"event": "presence.update",
|
||||||
|
"user_id": user_id,
|
||||||
|
"status": "online"
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(f"Heartbeat from {user_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown presence event: {event}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Invalid JSON from client")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
ws_manager.disconnect_from_presence(websocket)
|
||||||
|
|
||||||
|
# Видалити з Redis
|
||||||
|
if current_user_id:
|
||||||
|
logger.info(f"User {current_user_id} disconnected, presence will expire via TTL")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Background Task: Presence Cleanup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def presence_cleanup_task():
|
||||||
|
"""
|
||||||
|
Background task для очищення offline користувачів
|
||||||
|
Запускається кожні 60 секунд
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
# Redis автоматично видаляє keys з TTL
|
||||||
|
# Тут можна додати додаткову логіку якщо потрібно
|
||||||
|
online_users = await PresenceRedis.get_all_online()
|
||||||
|
logger.info(f"Presence cleanup: {len(online_users)} users online")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Presence cleanup error: {e}")
|
||||||
|
|
||||||
646
services/matrix-gateway/API_SPEC.md
Normal file
646
services/matrix-gateway/API_SPEC.md
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
# Matrix Gateway API Specification
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Service:** matrix-gateway
|
||||||
|
**Port:** 7003 (internal only)
|
||||||
|
**Purpose:** Internal service for Matrix protocol integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The **matrix-gateway** is an internal service that:
|
||||||
|
- Wraps Matrix Client-Server API
|
||||||
|
- Provides simplified endpoints for DAARION services
|
||||||
|
- Handles Matrix authentication and session management
|
||||||
|
- Transforms Matrix events ↔ DAARION internal DTOs
|
||||||
|
- Manages webhook subscriptions for real-time events
|
||||||
|
|
||||||
|
**NOT exposed to public internet** — internal service mesh only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ messaging-service│
|
||||||
|
│ │
|
||||||
|
│ (DAARION API) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ HTTP (internal)
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ matrix-gateway │
|
||||||
|
│ │
|
||||||
|
│ (Matrix API) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ Matrix C-S API
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Matrix Homeserver│
|
||||||
|
│ (Synapse) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All requests must include:
|
||||||
|
```
|
||||||
|
X-Internal-Service-Token: <shared-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Services authenticate via shared secret (not Matrix access tokens).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. Room Management
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/create-room**
|
||||||
|
|
||||||
|
Create a new Matrix room.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "General Discussion",
|
||||||
|
"topic": "Main DAARION.city channel",
|
||||||
|
"visibility": "public",
|
||||||
|
"room_alias_name": "general",
|
||||||
|
"preset": "public_chat",
|
||||||
|
"initial_state": [
|
||||||
|
{
|
||||||
|
"type": "m.room.guest_access",
|
||||||
|
"state_key": "",
|
||||||
|
"content": { "guest_access": "can_join" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"power_level_content_override": {
|
||||||
|
"users": {
|
||||||
|
"@daarion:daarion.city": 100
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"m.room.name": 50,
|
||||||
|
"m.room.topic": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"creation_content": {
|
||||||
|
"m.federate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"room_alias": "#general:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
- 400: Invalid request
|
||||||
|
- 500: Matrix API error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **GET /internal/matrix/rooms/{roomId}**
|
||||||
|
|
||||||
|
Get room details.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"name": "General Discussion",
|
||||||
|
"topic": "Main DAARION.city channel",
|
||||||
|
"avatar_url": "mxc://daarion.city/avatar123",
|
||||||
|
"canonical_alias": "#general:daarion.city",
|
||||||
|
"member_count": 42,
|
||||||
|
"joined_members": 38,
|
||||||
|
"encryption": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **PUT /internal/matrix/rooms/{roomId}/name**
|
||||||
|
|
||||||
|
Update room name.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Room Name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "$event123:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Message Sending
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/send**
|
||||||
|
|
||||||
|
Send a message to a room.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"sender": "agent:sofia",
|
||||||
|
"sender_matrix_id": "@sofia:daarion.city",
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hello from DAARION!",
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": "<p>Hello from <strong>DAARION</strong>!</p>",
|
||||||
|
"relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
"event_id": "$parent_event:daarion.city"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "$event456:daarion.city",
|
||||||
|
"sent_at": "2025-11-24T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported msgtypes:**
|
||||||
|
- `m.text` — plain text
|
||||||
|
- `m.image` — image
|
||||||
|
- `m.file` — file attachment
|
||||||
|
- `m.audio` — audio
|
||||||
|
- `m.video` — video
|
||||||
|
- `m.notice` — bot/agent notice
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/send-reaction**
|
||||||
|
|
||||||
|
React to a message.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"event_id": "$target_event:daarion.city",
|
||||||
|
"reactor": "user:alice",
|
||||||
|
"reactor_matrix_id": "@alice:daarion.city",
|
||||||
|
"emoji": "👍"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "$reaction789:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/redact**
|
||||||
|
|
||||||
|
Redact (delete) a message.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"event_id": "$event_to_delete:daarion.city",
|
||||||
|
"reason": "Spam"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "$redaction999:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Membership
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/invite**
|
||||||
|
|
||||||
|
Invite a user/agent to a room.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"user_id": "@alice:daarion.city",
|
||||||
|
"inviter": "user:admin",
|
||||||
|
"inviter_matrix_id": "@admin:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "invited"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/join**
|
||||||
|
|
||||||
|
Join a room (on behalf of user/agent).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"user_id": "@alice:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "joined",
|
||||||
|
"room_id": "!general:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/leave**
|
||||||
|
|
||||||
|
Leave a room.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"user_id": "@alice:daarion.city"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/kick**
|
||||||
|
|
||||||
|
Kick a user from a room.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"user_id": "@spammer:daarion.city",
|
||||||
|
"kicker": "@admin:daarion.city",
|
||||||
|
"reason": "Violation of rules"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "kicked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Event Sync
|
||||||
|
|
||||||
|
#### **GET /internal/matrix/sync**
|
||||||
|
|
||||||
|
Get recent events (polling mode).
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `since` — sync token (optional)
|
||||||
|
- `timeout` — long-polling timeout in ms (default 30000)
|
||||||
|
- `filter` — JSON filter (optional)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"next_batch": "s1234_567_8_9_10",
|
||||||
|
"rooms": {
|
||||||
|
"join": {
|
||||||
|
"!general:daarion.city": {
|
||||||
|
"timeline": {
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"type": "m.room.message",
|
||||||
|
"event_id": "$event123:daarion.city",
|
||||||
|
"sender": "@alice:daarion.city",
|
||||||
|
"content": {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hello!"
|
||||||
|
},
|
||||||
|
"origin_server_ts": 1732445400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limited": false,
|
||||||
|
"prev_batch": "p1234_567"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/webhook/subscribe**
|
||||||
|
|
||||||
|
Subscribe to room events via webhook.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"webhook_url": "http://messaging-service:7004/webhooks/matrix-events",
|
||||||
|
"events": ["m.room.message", "m.room.member"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subscription_id": "sub-abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When events occur, matrix-gateway will POST to webhook_url:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subscription_id": "sub-abc123",
|
||||||
|
"room_id": "!general:daarion.city",
|
||||||
|
"event": {
|
||||||
|
"type": "m.room.message",
|
||||||
|
"event_id": "$event456:daarion.city",
|
||||||
|
"sender": "@bob:daarion.city",
|
||||||
|
"content": {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hi there"
|
||||||
|
},
|
||||||
|
"origin_server_ts": 1732445500000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **DELETE /internal/matrix/webhook/subscribe/{subscriptionId}**
|
||||||
|
|
||||||
|
Unsubscribe from webhook.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "unsubscribed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. User Management
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/register-user**
|
||||||
|
|
||||||
|
Register a new Matrix user (for agent or human).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"password": "generated-secure-password",
|
||||||
|
"display_name": "Alice",
|
||||||
|
"avatar_url": "mxc://daarion.city/avatar456",
|
||||||
|
"admin": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "@alice:daarion.city",
|
||||||
|
"access_token": "syt_...",
|
||||||
|
"device_id": "DEVICE123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **PUT /internal/matrix/users/{userId}/display-name**
|
||||||
|
|
||||||
|
Update user display name.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"display_name": "Alice (Updated)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "updated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **PUT /internal/matrix/users/{userId}/avatar**
|
||||||
|
|
||||||
|
Update user avatar.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"avatar_url": "mxc://daarion.city/new-avatar"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "updated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Media Upload
|
||||||
|
|
||||||
|
#### **POST /internal/matrix/upload**
|
||||||
|
|
||||||
|
Upload media (for messages with images/files).
|
||||||
|
|
||||||
|
**Request:** `multipart/form-data`
|
||||||
|
- `file` — file to upload
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content_uri": "mxc://daarion.city/file123",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size": 102400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Room History
|
||||||
|
|
||||||
|
#### **GET /internal/matrix/rooms/{roomId}/messages**
|
||||||
|
|
||||||
|
Get paginated message history.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
- `from` — pagination token (required)
|
||||||
|
- `dir` — `b` (backwards) or `f` (forwards), default `b`
|
||||||
|
- `limit` — max events, default 10
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"start": "t1234_567",
|
||||||
|
"end": "t1234_500",
|
||||||
|
"chunk": [
|
||||||
|
{
|
||||||
|
"type": "m.room.message",
|
||||||
|
"event_id": "$event789:daarion.city",
|
||||||
|
"sender": "@charlie:daarion.city",
|
||||||
|
"content": {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Previous message"
|
||||||
|
},
|
||||||
|
"origin_server_ts": 1732445300000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Types (Matrix → DAARION mapping)
|
||||||
|
|
||||||
|
| Matrix Event Type | DAARION Internal Event |
|
||||||
|
|-------------------|------------------------|
|
||||||
|
| `m.room.message` (msgtype=m.text) | `messaging.message.created` |
|
||||||
|
| `m.room.message` (msgtype=m.image) | `messaging.media.uploaded` |
|
||||||
|
| `m.room.member` (join) | `messaging.member.joined` |
|
||||||
|
| `m.room.member` (leave) | `messaging.member.left` |
|
||||||
|
| `m.room.member` (invite) | `messaging.member.invited` |
|
||||||
|
| `m.room.name` | `messaging.channel.updated` |
|
||||||
|
| `m.room.topic` | `messaging.channel.updated` |
|
||||||
|
| `m.reaction` | `messaging.reaction.added` |
|
||||||
|
| `m.room.redaction` | `messaging.message.deleted` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All errors follow the format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "M_FORBIDDEN",
|
||||||
|
"message": "You are not allowed to send messages in this room"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common error codes:
|
||||||
|
- `M_FORBIDDEN` — Insufficient permissions
|
||||||
|
- `M_NOT_FOUND` — Room/user not found
|
||||||
|
- `M_UNKNOWN` — Generic Matrix error
|
||||||
|
- `M_BAD_JSON` — Invalid request payload
|
||||||
|
- `INTERNAL_ERROR` — matrix-gateway internal error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `MATRIX_HOMESERVER_URL` — e.g. `https://matrix.daarion.city`
|
||||||
|
- `MATRIX_ADMIN_TOKEN` — admin access token for homeserver operations
|
||||||
|
- `INTERNAL_SERVICE_SECRET` — shared secret for service-to-service auth
|
||||||
|
- `WEBHOOK_TIMEOUT_MS` — timeout for webhook deliveries (default 5000)
|
||||||
|
- `SYNC_TIMEOUT_MS` — long-polling timeout (default 30000)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
1. **User impersonation**: matrix-gateway can send messages on behalf of any user/agent (using admin privileges or shared secret registration).
|
||||||
|
|
||||||
|
2. **Event transformation**: All Matrix events are enriched with DAARION entity IDs (user:..., agent:...) before forwarding to services.
|
||||||
|
|
||||||
|
3. **Webhook reliability**: Webhooks are retried 3 times with exponential backoff. Failed events are logged but not re-queued.
|
||||||
|
|
||||||
|
4. **Rate limiting**: matrix-gateway implements internal rate limiting to avoid overwhelming the homeserver (max 100 req/s per service).
|
||||||
|
|
||||||
|
5. **Caching**: Room metadata (name, topic, members) is cached for 5 minutes to reduce load on Matrix homeserver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the provided `matrix-gateway-test.http` file for manual testing:
|
||||||
|
|
||||||
|
```http
|
||||||
|
### Create room
|
||||||
|
POST http://localhost:7003/internal/matrix/create-room
|
||||||
|
X-Internal-Service-Token: dev-secret-token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Test Room",
|
||||||
|
"topic": "Testing",
|
||||||
|
"visibility": "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Send message
|
||||||
|
POST http://localhost:7003/internal/matrix/send
|
||||||
|
X-Internal-Service-Token: dev-secret-token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"room_id": "!test:daarion.city",
|
||||||
|
"sender": "agent:test",
|
||||||
|
"sender_matrix_id": "@test:daarion.city",
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hello from test!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Last Updated:** 2025-11-24
|
||||||
|
**Maintainer:** DAARION Platform Team
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
17
services/matrix-gateway/Dockerfile
Normal file
17
services/matrix-gateway/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 7025
|
||||||
|
|
||||||
|
# Run
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7025"]
|
||||||
|
|
||||||
29
services/matrix-gateway/config.py
Normal file
29
services/matrix-gateway/config.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Matrix Gateway Configuration
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Service
|
||||||
|
service_name: str = "matrix-gateway"
|
||||||
|
service_version: str = "1.0.0"
|
||||||
|
port: int = 7025
|
||||||
|
|
||||||
|
# Synapse
|
||||||
|
synapse_url: str = "http://daarion-synapse:8008"
|
||||||
|
synapse_admin_token: str = ""
|
||||||
|
matrix_server_name: str = "daarion.space"
|
||||||
|
|
||||||
|
# Registration secret (for creating rooms as admin)
|
||||||
|
synapse_registration_secret: str = "daarion_reg_secret_2024"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_prefix = "MATRIX_GATEWAY_"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
324
services/matrix-gateway/main.py
Normal file
324
services/matrix-gateway/main.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
Matrix Gateway Service
|
||||||
|
Provides internal API for Matrix operations (room creation, lookup, etc.)
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="DAARION Matrix Gateway",
|
||||||
|
description="Internal API for Matrix operations",
|
||||||
|
version=settings.service_version
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS (internal service, but add for flexibility)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Admin access token (will be set on first request)
|
||||||
|
_admin_token: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Models
|
||||||
|
class CreateRoomRequest(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
visibility: str = "public"
|
||||||
|
topic: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoomResponse(BaseModel):
|
||||||
|
matrix_room_id: str
|
||||||
|
matrix_room_alias: str
|
||||||
|
|
||||||
|
|
||||||
|
class FindRoomResponse(BaseModel):
|
||||||
|
matrix_room_id: str
|
||||||
|
matrix_room_alias: str
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
error: str
|
||||||
|
detail: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
synapse: str
|
||||||
|
server_name: str
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_token() -> str:
|
||||||
|
"""Get or create admin access token for Matrix operations."""
|
||||||
|
global _admin_token
|
||||||
|
|
||||||
|
if _admin_token and settings.synapse_admin_token:
|
||||||
|
return settings.synapse_admin_token
|
||||||
|
|
||||||
|
if _admin_token:
|
||||||
|
return _admin_token
|
||||||
|
|
||||||
|
# Try to use provided token
|
||||||
|
if settings.synapse_admin_token:
|
||||||
|
_admin_token = settings.synapse_admin_token
|
||||||
|
return _admin_token
|
||||||
|
|
||||||
|
# Create admin user and get token
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Get nonce
|
||||||
|
nonce_resp = await client.get(
|
||||||
|
f"{settings.synapse_url}/_synapse/admin/v1/register"
|
||||||
|
)
|
||||||
|
nonce_resp.raise_for_status()
|
||||||
|
nonce = nonce_resp.json()["nonce"]
|
||||||
|
|
||||||
|
# Generate MAC
|
||||||
|
mac = hmac.new(
|
||||||
|
key=settings.synapse_registration_secret.encode('utf-8'),
|
||||||
|
digestmod=hashlib.sha1
|
||||||
|
)
|
||||||
|
mac.update(nonce.encode('utf-8'))
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(b"daarion_admin")
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(b"admin_password_2024")
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(b"admin")
|
||||||
|
|
||||||
|
# Register admin
|
||||||
|
register_resp = await client.post(
|
||||||
|
f"{settings.synapse_url}/_synapse/admin/v1/register",
|
||||||
|
json={
|
||||||
|
"nonce": nonce,
|
||||||
|
"username": "daarion_admin",
|
||||||
|
"password": "admin_password_2024",
|
||||||
|
"admin": True,
|
||||||
|
"mac": mac.hexdigest()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if register_resp.status_code == 200:
|
||||||
|
result = register_resp.json()
|
||||||
|
_admin_token = result.get("access_token")
|
||||||
|
logger.info("Admin user created successfully")
|
||||||
|
return _admin_token
|
||||||
|
elif register_resp.status_code == 400:
|
||||||
|
# User already exists, try to login
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{settings.synapse_url}/_matrix/client/v3/login",
|
||||||
|
json={
|
||||||
|
"type": "m.login.password",
|
||||||
|
"user": "daarion_admin",
|
||||||
|
"password": "admin_password_2024"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
login_resp.raise_for_status()
|
||||||
|
result = login_resp.json()
|
||||||
|
_admin_token = result.get("access_token")
|
||||||
|
logger.info("Admin user logged in successfully")
|
||||||
|
return _admin_token
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to create admin: {register_resp.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get admin token: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get admin token: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
synapse_status = "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(f"{settings.synapse_url}/_matrix/client/versions")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
synapse_status = "connected"
|
||||||
|
else:
|
||||||
|
synapse_status = "error"
|
||||||
|
except Exception:
|
||||||
|
synapse_status = "unavailable"
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok" if synapse_status == "connected" else "degraded",
|
||||||
|
synapse=synapse_status,
|
||||||
|
server_name=settings.matrix_server_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/matrix/rooms/create", response_model=CreateRoomResponse)
|
||||||
|
async def create_room(request: CreateRoomRequest):
|
||||||
|
"""
|
||||||
|
Create a Matrix room for a City Room.
|
||||||
|
|
||||||
|
This is an internal endpoint - should only be called by city-service.
|
||||||
|
"""
|
||||||
|
admin_token = await get_admin_token()
|
||||||
|
|
||||||
|
room_alias_name = f"city_{request.slug}"
|
||||||
|
room_name = f"DAARION City — {request.name}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
# Create room
|
||||||
|
create_resp = await client.post(
|
||||||
|
f"{settings.synapse_url}/_matrix/client/v3/createRoom",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
json={
|
||||||
|
"name": room_name,
|
||||||
|
"room_alias_name": room_alias_name,
|
||||||
|
"topic": request.topic or f"City room: {request.name}",
|
||||||
|
"preset": "public_chat" if request.visibility == "public" else "private_chat",
|
||||||
|
"visibility": "public" if request.visibility == "public" else "private",
|
||||||
|
"creation_content": {
|
||||||
|
"m.federate": False # Don't federate for now
|
||||||
|
},
|
||||||
|
"initial_state": [
|
||||||
|
{
|
||||||
|
"type": "m.room.history_visibility",
|
||||||
|
"content": {"history_visibility": "shared"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "m.room.guest_access",
|
||||||
|
"content": {"guest_access": "can_join"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if create_resp.status_code == 200:
|
||||||
|
result = create_resp.json()
|
||||||
|
matrix_room_id = result["room_id"]
|
||||||
|
matrix_room_alias = f"#city_{request.slug}:{settings.matrix_server_name}"
|
||||||
|
|
||||||
|
logger.info(f"Created Matrix room: {matrix_room_id} ({matrix_room_alias})")
|
||||||
|
|
||||||
|
return CreateRoomResponse(
|
||||||
|
matrix_room_id=matrix_room_id,
|
||||||
|
matrix_room_alias=matrix_room_alias
|
||||||
|
)
|
||||||
|
elif create_resp.status_code == 400:
|
||||||
|
error = create_resp.json()
|
||||||
|
if "M_ROOM_IN_USE" in str(error):
|
||||||
|
# Room already exists, find it
|
||||||
|
alias = f"#city_{request.slug}:{settings.matrix_server_name}"
|
||||||
|
find_resp = await client.get(
|
||||||
|
f"{settings.synapse_url}/_matrix/client/v3/directory/room/{alias.replace('#', '%23').replace(':', '%3A')}",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
if find_resp.status_code == 200:
|
||||||
|
room_info = find_resp.json()
|
||||||
|
return CreateRoomResponse(
|
||||||
|
matrix_room_id=room_info["room_id"],
|
||||||
|
matrix_room_alias=alias
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.error(f"Failed to create room: {create_resp.text}")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Matrix error: {error.get('error', 'Unknown')}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create room: {create_resp.text}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create Matrix room")
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Matrix request error: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail="Matrix unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/internal/matrix/rooms/find-by-alias", response_model=FindRoomResponse)
|
||||||
|
async def find_room_by_alias(alias: str = Query(..., description="Matrix room alias")):
|
||||||
|
"""
|
||||||
|
Find a Matrix room by its alias.
|
||||||
|
|
||||||
|
Example: ?alias=#city_general:daarion.space
|
||||||
|
"""
|
||||||
|
admin_token = await get_admin_token()
|
||||||
|
|
||||||
|
# URL encode the alias
|
||||||
|
encoded_alias = alias.replace("#", "%23").replace(":", "%3A")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.synapse_url}/_matrix/client/v3/directory/room/{encoded_alias}",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
result = resp.json()
|
||||||
|
return FindRoomResponse(
|
||||||
|
matrix_room_id=result["room_id"],
|
||||||
|
matrix_room_alias=alias
|
||||||
|
)
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to find room: {resp.text}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to find room")
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Matrix request error: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail="Matrix unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/internal/matrix/rooms/{room_id}")
|
||||||
|
async def get_room_info(room_id: str):
|
||||||
|
"""Get information about a Matrix room."""
|
||||||
|
admin_token = await get_admin_token()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.synapse_url}/_matrix/client/v3/rooms/{room_id}/state",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
state = resp.json()
|
||||||
|
# Extract room info from state
|
||||||
|
name = None
|
||||||
|
topic = None
|
||||||
|
|
||||||
|
for event in state:
|
||||||
|
if event.get("type") == "m.room.name":
|
||||||
|
name = event.get("content", {}).get("name")
|
||||||
|
elif event.get("type") == "m.room.topic":
|
||||||
|
topic = event.get("content", {}).get("topic")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"room_id": room_id,
|
||||||
|
"name": name,
|
||||||
|
"topic": topic
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=resp.status_code, detail="Failed to get room info")
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Matrix request error: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail="Matrix unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=settings.port)
|
||||||
|
|
||||||
6
services/matrix-gateway/requirements.txt
Normal file
6
services/matrix-gateway/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
httpx==0.26.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
Reference in New Issue
Block a user