feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
This commit is contained in:
497
docs/tasks/TASK_PHASE_CITY_BACKEND_FINISHER.md
Normal file
497
docs/tasks/TASK_PHASE_CITY_BACKEND_FINISHER.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# TASK_PHASE_CITY_BACKEND_FINISHER.md
|
||||
|
||||
DAARION CITY — Backend Completion for Phase 3 (MVP)
|
||||
|
||||
Цей таск **закриває City Backend** до рівня, коли MVP можна деплоїти на сервер (daarion.space) і реально користуватись:
|
||||
|
||||
- Public Rooms (міські кімнати)
|
||||
- Presence System (онлайн-статуси)
|
||||
- Second Me (персональний агент MVP)
|
||||
- City Home інтеграція (дані для дашборду міста)
|
||||
|
||||
Фронтенд уже реалізований (CityRoomsPage, SecondMePage, PresenceBar тощо),
|
||||
цей таск — про **backend-реалізацію API + WS + Redis + DB + інтеграцію з Agents Core**.
|
||||
|
||||
---
|
||||
|
||||
## 0. База / припущення
|
||||
|
||||
1. Primary DB: **PostgreSQL** (той самий, що й для microdao).
|
||||
2. Cache / presence: **Redis** (ok додати новий контейнер або використовувати існуючий).
|
||||
3. Message bus: **NATS JetStream** (вже є для Agents Core).
|
||||
4. HTTP API gateway: уже налаштований (`/api/...`, `/ws/...`), ти додаєш нові маршрути.
|
||||
5. Існує **Agents Core** з endpoints `/agents/{id}/invoke` і NATS-темами `agents.invoke` / `agents.reply`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Структура Backend-модулів City
|
||||
|
||||
Створити (або доповнити, якщо частково вже є):
|
||||
|
||||
```text
|
||||
services/
|
||||
city-service/
|
||||
__init__.py
|
||||
models.py
|
||||
schemas.py
|
||||
routes_city.py
|
||||
ws_city.py
|
||||
presence.py
|
||||
feed.py
|
||||
rooms.py
|
||||
repo.py
|
||||
secondme-service/
|
||||
__init__.py
|
||||
models.py
|
||||
schemas.py
|
||||
routes_secondme.py
|
||||
service_secondme.py
|
||||
common/
|
||||
redis_client.py # якщо ще немає
|
||||
```
|
||||
|
||||
І підключити:
|
||||
|
||||
* `routes_city.py` і `routes_secondme.py` до основного `main.py` (або відповідного API-aggregator service).
|
||||
* `ws_city.py` — до WebSocket router'а (`/ws/...`).
|
||||
|
||||
---
|
||||
|
||||
## 2. PostgreSQL: нові таблиці
|
||||
|
||||
### 2.1 Таблиця `city_rooms`
|
||||
|
||||
```sql
|
||||
create table city_rooms (
|
||||
id text primary key, -- room_id, напр. "room_city_general"
|
||||
slug text not null unique, -- "general", "science"
|
||||
name text not null, -- "General", "Science"
|
||||
description text null,
|
||||
is_default boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
created_by text null -- user_id (u_*)
|
||||
);
|
||||
create index ix_city_rooms_slug on city_rooms(slug);
|
||||
```
|
||||
|
||||
### 2.2 Таблиця `city_room_messages`
|
||||
|
||||
```sql
|
||||
create table city_room_messages (
|
||||
id text primary key, -- ksuid/ulid, префікс m_city_
|
||||
room_id text not null references city_rooms(id) on delete cascade,
|
||||
author_user_id text null, -- u_*
|
||||
author_agent_id text null, -- ag_*
|
||||
body text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index ix_city_room_messages_room_time on city_room_messages(room_id, created_at desc);
|
||||
```
|
||||
|
||||
### 2.3 Таблиця `city_feed_events`
|
||||
|
||||
```sql
|
||||
create table city_feed_events (
|
||||
id text primary key, -- evt_city_*
|
||||
kind text not null, -- 'room_message','agent_reply','system'
|
||||
room_id text null references city_rooms(id) on delete set null,
|
||||
user_id text null,
|
||||
agent_id text null,
|
||||
payload jsonb not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index ix_city_feed_time on city_feed_events(created_at desc);
|
||||
```
|
||||
|
||||
### 2.4 Таблиця `secondme_sessions` (історія Second Me)
|
||||
|
||||
```sql
|
||||
create table secondme_sessions (
|
||||
id text primary key, -- smsess_*
|
||||
user_id text not null, -- u_*
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table secondme_messages (
|
||||
id text primary key, -- smmsg_*
|
||||
session_id text not null references secondme_sessions(id) on delete cascade,
|
||||
user_id text not null,
|
||||
role text not null check (role in ('user','assistant')),
|
||||
content text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index ix_secondme_messages_session_time on secondme_messages(session_id, created_at desc);
|
||||
```
|
||||
|
||||
Для MVP: можна використовувати **одну активну session per user** (останню).
|
||||
|
||||
---
|
||||
|
||||
## 3. Redis: Presence System
|
||||
|
||||
Використати Redis як KV-store для онлайн-присутності:
|
||||
|
||||
* key: `presence:user:{user_id}` → value: `"online"`
|
||||
* TTL: 40 секунд
|
||||
* WS heartbeat кожні 20 секунд оновлює TTL
|
||||
|
||||
### Redis-клієнт
|
||||
|
||||
`common/redis_client.py`:
|
||||
|
||||
```python
|
||||
import aioredis
|
||||
from typing import Optional
|
||||
|
||||
_redis = None
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
global _redis
|
||||
if _redis is None:
|
||||
_redis = await aioredis.from_url(
|
||||
os.getenv("REDIS_URL", "redis://redis:6379/0"),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return _redis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTP API — City Rooms / Feed
|
||||
|
||||
### 4.1 Маршрути (routes_city.py)
|
||||
|
||||
Base prefix: **`/city`**.
|
||||
|
||||
#### GET `/city/rooms`
|
||||
|
||||
* Повертає список всіх кімнат.
|
||||
* Query params: (optional) `limit`, `offset`.
|
||||
* Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "room_city_general",
|
||||
"slug": "general",
|
||||
"name": "General",
|
||||
"description": "Головна кімната міста",
|
||||
"members_online": 42,
|
||||
"last_event": "2025-11-23T10:15:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`members_online` рахувати через Redis:
|
||||
|
||||
* keys: `presence:user:*` → map users → rooms (див. нижче в Presence).
|
||||
|
||||
Для MVP можна:
|
||||
|
||||
* рахувати `members_online` приблизно: число унікальних `presence:user:*` (спрощено),
|
||||
* або додати key `presence:room:{room_id}` (більш точно).
|
||||
|
||||
#### POST `/city/rooms`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Science",
|
||||
"slug": "science",
|
||||
"description": "Наукова кімната"
|
||||
}
|
||||
```
|
||||
|
||||
* Генерує `id = room_city_{slug}`.
|
||||
* Створює запис у `city_rooms`.
|
||||
* Віддає створену кімнату.
|
||||
|
||||
#### GET `/city/rooms/{room_id}`
|
||||
|
||||
Returns:
|
||||
|
||||
* room meta
|
||||
* останні 50 повідомлень
|
||||
* приблизний `members_online`
|
||||
|
||||
```json
|
||||
{
|
||||
"room": {
|
||||
"id": "room_city_general",
|
||||
"name": "General",
|
||||
"description": "Головна кімната міста"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"id": "m_city_...",
|
||||
"author_user_id": "u_123",
|
||||
"author_agent_id": null,
|
||||
"body": "Привіт місто!",
|
||||
"created_at": "..."
|
||||
}
|
||||
],
|
||||
"members_online": 12
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/city/rooms/{room_id}/messages`
|
||||
|
||||
* Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"body": "Текст повідомлення"
|
||||
}
|
||||
```
|
||||
|
||||
* Запис у `city_room_messages`.
|
||||
* Запис у `city_feed_events` з kind = `"room_message"`.
|
||||
* Публікація WS event (див. WS нижче).
|
||||
* Повертає створене повідомлення.
|
||||
|
||||
#### GET `/city/feed`
|
||||
|
||||
* Повертає останні N (наприклад, 20) подій:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "evt_city_...",
|
||||
"kind": "room_message",
|
||||
"room_id": "room_city_general",
|
||||
"user_id": "u_123",
|
||||
"payload": {"body": "Текст...", "snippet": "..."},
|
||||
"created_at": "..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. WebSocket — City Rooms + Presence
|
||||
|
||||
### 5.1 City Rooms WS (`ws_city.py`)
|
||||
|
||||
Шлях (через already existing WS server):
|
||||
|
||||
```
|
||||
/ws/city/rooms/{room_id}
|
||||
```
|
||||
|
||||
Події (JSON):
|
||||
|
||||
* Вхідні від клієнта:
|
||||
|
||||
```json
|
||||
{ "event": "room.join", "room_id": "room_city_general" }
|
||||
{ "event": "room.leave", "room_id": "room_city_general" }
|
||||
{ "event": "room.message.send", "room_id": "...", "body": "..." }
|
||||
```
|
||||
|
||||
* Вихідні до клієнтів:
|
||||
|
||||
```json
|
||||
{ "event": "room.message", "room_id": "...", "message": { ... } }
|
||||
{ "event": "room.join", "room_id": "...", "user_id": "u_123" }
|
||||
{ "event": "room.leave", "room_id": "...", "user_id": "u_123" }
|
||||
{ "event": "room.presence", "room_id": "...", "user_id": "u_123", "status": "online" }
|
||||
```
|
||||
|
||||
При `room.message.send`:
|
||||
|
||||
1. зберегти в DB (`city_room_messages`)
|
||||
2. зберегти в `city_feed_events`
|
||||
3. розіслати WS подію `room.message` усім підписникам
|
||||
|
||||
### 5.2 Presence WS (`/ws/city/presence`)
|
||||
|
||||
* Вхідні:
|
||||
|
||||
```json
|
||||
{ "event": "presence.heartbeat", "user_id": "u_123" }
|
||||
```
|
||||
|
||||
Обробка:
|
||||
|
||||
1. `SETEX presence:user:u_123 "online" 40`
|
||||
2. broadcast (опційно) `presence.update`:
|
||||
|
||||
```json
|
||||
{ "event": "presence.update", "user_id": "u_123", "status": "online" }
|
||||
```
|
||||
|
||||
3. Періодично (background task, наприклад кожні 30 сек):
|
||||
|
||||
* сканувати `presence:user:*`,
|
||||
* якщо TTL минув (Redis сам видаляє keys), ws-клієнтам можна розіслати `presence.update` зі статусом `"offline"` (або робити lazy-оновлення при наступних запитах).
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend для Second Me
|
||||
|
||||
### 6.1 Service logic (`secondme-service/service_secondme.py`)
|
||||
|
||||
Функції:
|
||||
|
||||
```python
|
||||
async def get_or_create_session(user_id: str) -> SecondMeSession:
|
||||
# бере останню сесію або створює нову
|
||||
|
||||
async def get_last_messages(user_id: str, limit: int = 5) -> list[SecondMeMessage]:
|
||||
# повертає останні 5 повідомлень з secondme_messages
|
||||
|
||||
async def invoke_second_me(user_id: str, prompt: str) -> SecondMeMessage:
|
||||
# 1. get_or_create_session
|
||||
# 2. зберегти user-повідомлення
|
||||
# 3. зібрати короткий контекст (останні N повідомлень)
|
||||
# 4. викликати Agents Core:
|
||||
# POST /agents/{second_me_agent_id}/invoke
|
||||
# або NATS publish agents.invoke
|
||||
# 5. зберегти assistant-відповідь
|
||||
# 6. повернути відповідь
|
||||
```
|
||||
|
||||
`second_me_agent_id` поки можна:
|
||||
|
||||
* або hardcode (один глобальний Second Me agent),
|
||||
* або зберігати у таблиці `users` / `agents` як поле.
|
||||
|
||||
Для MVP — допустимо hardcode у конфігу.
|
||||
|
||||
### 6.2 API (`routes_secondme.py`)
|
||||
|
||||
#### POST `/secondme/invoke`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{ "prompt": "..." }
|
||||
```
|
||||
|
||||
З HTTP-контексту брати `user_id` (із JWT).
|
||||
Викликати `invoke_second_me`, повернути:
|
||||
|
||||
```json
|
||||
{
|
||||
"reply": "Текст відповіді",
|
||||
"history": [
|
||||
{"role": "user", "content": "..."},
|
||||
{"role": "assistant", "content": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### GET `/secondme/history`
|
||||
|
||||
Query: (optional) `limit`, default 5.
|
||||
Повернути:
|
||||
|
||||
```json
|
||||
[
|
||||
{"role": "user", "content": "...", "created_at": "..."},
|
||||
{"role": "assistant", "content": "...", "created_at": "..."}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Інтеграція з Agents Core
|
||||
|
||||
Для Second Me використати стандартний шлях:
|
||||
|
||||
* або **HTTP-level**:
|
||||
|
||||
```text
|
||||
POST /agents/{second_me_agent_id}/invoke
|
||||
{
|
||||
"input": "...prompt+context...",
|
||||
"context": { "user_id": "...", "kind": "secondme" }
|
||||
}
|
||||
```
|
||||
|
||||
* або **NATS** (якщо вже зручно):
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agents.invoke",
|
||||
"agent_id": "ag_secondme",
|
||||
"payload": {
|
||||
"input": "...",
|
||||
"user_id": "u_123",
|
||||
"source": "secondme"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Для MVP допустимо використати HTTP-виклик до Agents Core service.
|
||||
|
||||
---
|
||||
|
||||
## 8. Конфігурація (ENV)
|
||||
|
||||
Додати змінні:
|
||||
|
||||
```env
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
SECONDME_AGENT_ID=ag_secondme_global
|
||||
CITY_DEFAULT_ROOMS=general,welcome,builders
|
||||
```
|
||||
|
||||
При старті `city-service`:
|
||||
|
||||
* якщо `CITY_DEFAULT_ROOMS` порожній → створити дефолтні кімнати:
|
||||
|
||||
* `room_city_general` ("General")
|
||||
* `room_city_welcome` ("Welcome")
|
||||
* `room_city_builders` ("Builders")
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance Criteria
|
||||
|
||||
### 9.1 Public Rooms
|
||||
|
||||
* `GET /city/rooms` повертає список кімнат.
|
||||
* `POST /city/rooms` створює кімнату, видно в фронтенді.
|
||||
* `GET /city/rooms/{room_id}` повертає кімнату та останні повідомлення.
|
||||
* `POST /city/rooms/{room_id}/messages`:
|
||||
|
||||
* зберігає повідомлення в DB,
|
||||
* відправляє WS-івент `room.message`,
|
||||
* додає запис у `city_feed_events`.
|
||||
|
||||
### 9.2 Presence
|
||||
|
||||
* при підключенні фронтенду до `/ws/city/presence` і надсиланні `presence.heartbeat`:
|
||||
|
||||
* Redis має ключ `presence:user:<user_id>` із TTL ~40с;
|
||||
* інші клієнти отримують `presence.update` (online).
|
||||
|
||||
* при припиненні heartbeat ключ зникає → статус offline (або lazily оновлюється).
|
||||
|
||||
### 9.3 Second Me
|
||||
|
||||
* `POST /secondme/invoke`:
|
||||
|
||||
* робить виклик до Agents Core,
|
||||
* повертає текст відповіді,
|
||||
* історія зберігається у `secondme_messages`.
|
||||
|
||||
* `GET /secondme/history` повертає останні N записів.
|
||||
|
||||
### 9.4 City Home
|
||||
|
||||
* `GET /city/feed` повертає події (room messages мінімум).
|
||||
* Frontend City Home (вже реалізований) отримує всі необхідні дані через API.
|
||||
|
||||
---
|
||||
|
||||
## 10. Команда до Cursor
|
||||
|
||||
**"Реалізувати backend для City MVP згідно TASK_PHASE_CITY_BACKEND_FINISHER.md.
|
||||
Створити city-service (rooms, feed, presence) та secondme-service.
|
||||
Інтегрувати з Redis (presence) та Agents Core (Second Me).
|
||||
Не змінювати існуючий фронтенд, тільки підключити API."**
|
||||
|
||||
Reference in New Issue
Block a user