diff --git a/apps/web/src/app/city/[slug]/page.tsx b/apps/web/src/app/city/[slug]/page.tsx index cff9618e..145fef06 100644 --- a/apps/web/src/app/city/[slug]/page.tsx +++ b/apps/web/src/app/city/[slug]/page.tsx @@ -1,9 +1,9 @@ import Link from 'next/link' -import { ArrowLeft, Users, FileText, Clock } from 'lucide-react' +import { ArrowLeft, Users, FileText, Clock, MessageCircle } from 'lucide-react' import { api, CityRoom } from '@/lib/api' import { formatDate } from '@/lib/utils' import { notFound } from 'next/navigation' -import { ChatRoom } from '@/components/chat/ChatRoom' +import { MatrixChatRoom } from '@/components/chat/MatrixChatRoom' // Force dynamic rendering - don't prerender at build time export const dynamic = 'force-dynamic' @@ -73,12 +73,21 @@ export default async function RoomPage({ params }: PageProps) { {/* Chat Area */}
- +
+ + {/* Matrix Room Info */} + {room.matrix_room_id && ( +
+
+ + Matrix Room: + + {room.matrix_room_alias || room.matrix_room_id} + +
+
+ )}
{/* Sidebar */} diff --git a/apps/web/src/components/chat/MatrixChatRoom.tsx b/apps/web/src/components/chat/MatrixChatRoom.tsx new file mode 100644 index 00000000..8346cc31 --- /dev/null +++ b/apps/web/src/components/chat/MatrixChatRoom.tsx @@ -0,0 +1,305 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle } from 'lucide-react' +import { ChatMessage } from './ChatMessage' +import { ChatInput } from './ChatInput' +import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage } from '@/lib/matrix-client' +import { cn } from '@/lib/utils' +import { useAuth } from '@/context/AuthContext' + +interface MatrixChatRoomProps { + roomSlug: string +} + +type ConnectionStatus = 'loading' | 'connecting' | 'online' | 'error' | 'unauthenticated' + +interface BootstrapData { + matrix_hs_url: string + matrix_user_id: string + matrix_access_token: string + matrix_device_id: string + matrix_room_id: string + matrix_room_alias: string + room: { + id: string + slug: string + name: string + description?: string + } +} + +export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) { + const { user, token } = useAuth() + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('loading') + const [error, setError] = useState(null) + const [bootstrap, setBootstrap] = useState(null) + const messagesEndRef = useRef(null) + const matrixClient = useRef(null) + + // Scroll to bottom when new messages arrive + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, scrollToBottom]) + + // Initialize Matrix connection + const initializeMatrix = useCallback(async () => { + if (!token) { + setStatus('unauthenticated') + return + } + + setStatus('loading') + setError(null) + + try { + // 1. Get bootstrap data + const res = await fetch(`/api/city/chat/bootstrap?room_slug=${roomSlug}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (!res.ok) { + const err = await res.json() + throw new Error(err.detail || 'Failed to get chat bootstrap') + } + + const data: BootstrapData = await res.json() + setBootstrap(data) + + // 2. Create Matrix client + setStatus('connecting') + const client = createMatrixClient(data) + matrixClient.current = client + + // 3. Join room + try { + await client.joinRoom(data.matrix_room_id) + } catch (e) { + // Ignore join errors (might already be in room) + console.log('Join room result:', e) + } + + // 4. Get initial messages + const messagesRes = await client.getMessages(data.matrix_room_id, { limit: 50 }) + const initialMessages = messagesRes.chunk + .filter(e => e.type === 'm.room.message' && e.content?.body) + .map(e => client.mapToChatMessage(e)) + .reverse() // Oldest first + + setMessages(initialMessages) + + // 5. Start sync for real-time updates + await client.initialSync() + client.startSync((newMessage) => { + setMessages(prev => { + // Avoid duplicates + if (prev.some(m => m.id === newMessage.id)) { + return prev + } + return [...prev, newMessage] + }) + }) + + setStatus('online') + } catch (err) { + console.error('Matrix initialization error:', err) + setError(err instanceof Error ? err.message : 'Unknown error') + setStatus('error') + } + }, [token, roomSlug]) + + useEffect(() => { + initializeMatrix() + + return () => { + matrixClient.current?.stopSync() + } + }, [initializeMatrix]) + + const handleSendMessage = async (body: string) => { + if (!matrixClient.current || !bootstrap) return + + try { + // Optimistically add message + const tempId = `temp_${Date.now()}` + const tempMessage: MatrixChatMessage = { + id: tempId, + senderId: bootstrap.matrix_user_id, + senderName: 'You', + text: body, + timestamp: new Date(), + isUser: true + } + setMessages(prev => [...prev, tempMessage]) + + // Send to Matrix + const result = await matrixClient.current.sendMessage(bootstrap.matrix_room_id, body) + + // Update temp message with real ID + setMessages(prev => prev.map(m => + m.id === tempId ? { ...m, id: result.event_id } : m + )) + } catch (err) { + console.error('Failed to send message:', err) + // Remove failed message + setMessages(prev => prev.filter(m => !m.id.startsWith('temp_'))) + setError('Не вдалося надіслати повідомлення') + } + } + + const handleRetry = () => { + initializeMatrix() + } + + // Map MatrixChatMessage to legacy format for ChatMessage component + const mapToLegacyFormat = (msg: MatrixChatMessage) => ({ + id: msg.id, + room_id: bootstrap?.room.id || '', + author_user_id: msg.isUser ? 'current_user' : msg.senderId, + author_agent_id: null, + body: msg.text, + created_at: msg.timestamp.toISOString() + }) + + return ( +
+ {/* Connection status */} +
+
+ + Matrix Chat + {bootstrap?.matrix_room_alias && ( + + {bootstrap.matrix_room_alias} + + )} +
+ +
+ {status === 'loading' && ( + <> + + Завантаження... + + )} + {status === 'connecting' && ( + <> + + Підключення до Matrix... + + )} + {status === 'online' && ( + <> + + Онлайн + + )} + {status === 'error' && ( + <> + + Помилка + + )} + {status === 'unauthenticated' && ( + <> + + Потрібен вхід + + )} +
+
+ + {/* Error / Auth required message */} + {(status === 'error' || status === 'unauthenticated') && ( +
+
+
+ + + {status === 'unauthenticated' + ? 'Увійдіть, щоб приєднатися до чату' + : error || 'Помилка підключення' + } + +
+ {status === 'error' && ( + + )} +
+
+ )} + + {/* Messages area */} +
+ {messages.length === 0 ? ( +
+
+ +
+

+ {status === 'online' + ? 'Поки що немає повідомлень' + : status === 'unauthenticated' + ? 'Увійдіть для доступу до чату' + : 'Підключення до Matrix...' + } +

+

+ {status === 'online' + ? 'Будьте першим, хто напише в цій кімнаті! Ваше повідомлення синхронізується з Matrix.' + : status === 'unauthenticated' + ? 'Для участі в чаті потрібна авторизація' + : 'Встановлюємо зʼєднання з Matrix сервером...' + } +

+
+ ) : ( + <> + {messages.map((message) => ( + + ))} +
+ + )} +
+ + {/* Input area */} + +
+ ) +} + diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 52819d48..a06eadcb 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -26,6 +26,9 @@ export interface CityRoom { created_by: string | null members_online: number last_event: string | null + // Matrix integration + matrix_room_id: string | null + matrix_room_alias: string | null } export interface SecondMeProfile { diff --git a/apps/web/src/lib/matrix-client.ts b/apps/web/src/lib/matrix-client.ts new file mode 100644 index 00000000..a78b1be1 --- /dev/null +++ b/apps/web/src/lib/matrix-client.ts @@ -0,0 +1,315 @@ +/** + * Lightweight Matrix REST Client for DAARION + * + * Uses Matrix Client-Server API directly without heavy SDK + */ + +export interface MatrixClientConfig { + baseUrl: string; + accessToken: string; + userId: string; + roomId?: string; +} + +export interface MatrixMessage { + event_id: string; + sender: string; + origin_server_ts: number; + content: { + msgtype: string; + body: string; + format?: string; + formatted_body?: string; + }; + type: string; +} + +export interface MatrixMessagesResponse { + chunk: MatrixMessage[]; + start: string; + end: string; +} + +export interface MatrixSyncResponse { + next_batch: string; + rooms?: { + join?: { + [roomId: string]: { + timeline?: { + events: MatrixMessage[]; + prev_batch?: string; + }; + state?: { + events: any[]; + }; + }; + }; + }; +} + +export interface ChatMessage { + id: string; + senderId: string; + senderName: string; + text: string; + timestamp: Date; + isUser: boolean; +} + +export class MatrixRestClient { + private baseUrl: string; + private accessToken: string; + private userId: string; + private roomId: string | null = null; + private syncToken: string | null = null; + private syncAbortController: AbortController | null = null; + private onMessageCallback: ((message: ChatMessage) => void) | null = null; + private isSyncing: boolean = false; + + constructor(config: MatrixClientConfig) { + this.baseUrl = config.baseUrl; + this.accessToken = config.accessToken; + this.userId = config.userId; + this.roomId = config.roomId || null; + } + + private authHeaders(): HeadersInit { + return { + 'Authorization': `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json' + }; + } + + /** + * Join a Matrix room + */ + async joinRoom(roomId: string): Promise<{ room_id: string }> { + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, + { + method: 'POST', + headers: this.authHeaders() + } + ); + + if (!res.ok) { + const error = await res.json(); + // M_FORBIDDEN means already joined or not allowed + if (error.errcode !== 'M_FORBIDDEN') { + throw new Error(error.error || 'Failed to join room'); + } + } + + this.roomId = roomId; + return res.json(); + } + + /** + * Get messages from a room + */ + async getMessages(roomId: string, options?: { limit?: number; from?: string; dir?: 'b' | 'f' }): Promise { + const params = new URLSearchParams({ + dir: options?.dir || 'b', + limit: String(options?.limit || 50), + filter: JSON.stringify({ types: ['m.room.message'] }) + }); + + if (options?.from) { + params.set('from', options.from); + } + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${params}`, + { headers: this.authHeaders() } + ); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Failed to get messages'); + } + + return res.json(); + } + + /** + * Send a text message to a room + */ + async sendMessage(roomId: string, body: string): Promise<{ event_id: string }> { + const txnId = `m${Date.now()}.${Math.random().toString(36).substr(2, 9)}`; + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + { + method: 'PUT', + headers: this.authHeaders(), + body: JSON.stringify({ + msgtype: 'm.text', + body: body + }) + } + ); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Failed to send message'); + } + + return res.json(); + } + + /** + * Perform initial sync to get sync token + */ + async initialSync(): Promise { + const params = new URLSearchParams({ + timeout: '0', + filter: JSON.stringify({ + room: { + timeline: { limit: 1 }, + state: { lazy_load_members: true } + } + }) + }); + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/sync?${params}`, + { headers: this.authHeaders() } + ); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Failed to sync'); + } + + const data = await res.json(); + this.syncToken = data.next_batch; + return data; + } + + /** + * Start long-polling for new messages + */ + startSync(onMessage: (message: ChatMessage) => void): void { + this.onMessageCallback = onMessage; + this.isSyncing = true; + this.syncLoop(); + } + + /** + * Stop syncing + */ + stopSync(): void { + this.isSyncing = false; + if (this.syncAbortController) { + this.syncAbortController.abort(); + this.syncAbortController = null; + } + } + + private async syncLoop(): Promise { + while (this.isSyncing) { + try { + this.syncAbortController = new AbortController(); + + const params = new URLSearchParams({ + timeout: '30000', + filter: JSON.stringify({ + room: { + timeline: { limit: 50 }, + state: { lazy_load_members: true } + } + }) + }); + + if (this.syncToken) { + params.set('since', this.syncToken); + } + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/sync?${params}`, + { + headers: this.authHeaders(), + signal: this.syncAbortController.signal + } + ); + + if (!res.ok) { + console.error('Sync failed:', await res.text()); + // Wait before retry + await new Promise(r => setTimeout(r, 5000)); + continue; + } + + const data: MatrixSyncResponse = await res.json(); + this.syncToken = data.next_batch; + + // Process new messages + if (data.rooms?.join && this.roomId) { + const roomData = data.rooms.join[this.roomId]; + if (roomData?.timeline?.events) { + for (const event of roomData.timeline.events) { + if (event.type === 'm.room.message' && event.content?.body) { + const chatMessage = this.mapToChatMessage(event); + this.onMessageCallback?.(chatMessage); + } + } + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Sync was stopped + break; + } + console.error('Sync error:', error); + // Wait before retry + await new Promise(r => setTimeout(r, 5000)); + } + } + } + + /** + * Map Matrix event to ChatMessage + */ + mapToChatMessage(event: MatrixMessage): ChatMessage { + // Extract display name from Matrix user ID + // @daarion_abc123:daarion.space -> User abc123 + const senderName = event.sender + .split(':')[0] + .replace('@daarion_', 'User ') + .replace('@', ''); + + return { + id: event.event_id, + senderId: event.sender, + senderName: senderName, + text: event.content.body, + timestamp: new Date(event.origin_server_ts), + isUser: event.sender === this.userId + }; + } + + /** + * Get current user ID + */ + getUserId(): string { + return this.userId; + } +} + +/** + * Create a Matrix client from bootstrap data + */ +export function createMatrixClient(bootstrap: { + matrix_hs_url: string; + matrix_user_id: string; + matrix_access_token: string; + matrix_room_id: string; +}): MatrixRestClient { + return new MatrixRestClient({ + baseUrl: bootstrap.matrix_hs_url, + accessToken: bootstrap.matrix_access_token, + userId: bootstrap.matrix_user_id, + roomId: bootstrap.matrix_room_id + }); +} + diff --git a/docs/matrix/MATRIX_CHAT_CLIENT_SPEC.md b/docs/matrix/MATRIX_CHAT_CLIENT_SPEC.md new file mode 100644 index 00000000..a0ebd84c --- /dev/null +++ b/docs/matrix/MATRIX_CHAT_CLIENT_SPEC.md @@ -0,0 +1,413 @@ +# MATRIX CHAT CLIENT — DAARION.city + +Version: 1.0.0 + +## 0. PURPOSE + +Зробити так, щоб сторінка `/city/[slug]` у DAARION UI була **повноцінним Matrix-чатом**: + +- використовує реальні Matrix rooms (`matrix_room_id`, `matrix_room_alias`), +- працює від імені поточного користувача DAARION, +- показує історію, нові повідомлення, статус підключення, +- використовує існуючий Chat Layout (UI), але замість тимчасового WebSocket — Matrix. + +Це базовий крок для подальшого: +- Presence / Typing / Read receipts, +- агентів як ботів, +- 2D/2.5D City Map з live-активністю. + +--- + +## 1. ARCHITECTURE OVERVIEW + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DAARION Frontend │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ /city/[slug] Page │ │ +│ │ ┌─────────────────┐ ┌────────────────────────────────┐ │ │ +│ │ │ Room Info │ │ Matrix Chat Client │ │ │ +│ │ │ (from API) │ │ - Connect to Synapse │ │ │ +│ │ └─────────────────┘ │ - Send/receive messages │ │ │ +│ │ │ - Show history │ │ │ +│ │ └────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend │ +│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────────┐ │ +│ │ auth-service│ │ city-service │ │ matrix-gateway │ │ +│ │ (7020) │ │ (7001) │ │ (7025) │ │ +│ │ │ │ │ │ │ │ +│ │ JWT tokens │ │ /chat/bootstrap │ │ /user/token │ │ +│ │ User→Matrix │ │ matrix_room_id │ │ Create rooms │ │ +│ └─────────────┘ └──────────────────┘ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Matrix Synapse (8018) │ +│ - Rooms: !xxx:daarion.space │ +│ - Users: @daarion_xxx:daarion.space │ +│ - Messages, history, sync │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Компоненти: + +- **auth-service** (7020) + - знає `user_id`, email, Matrix user mapping. + +- **matrix-gateway** (7025) + - вміє створювати кімнати (вже реалізовано), + - буде видавати Matrix access tokens для користувачів. + +- **city-service** (7001) + - надає `matrix_room_id` / `matrix_room_alias`, + - новий endpoint `/chat/bootstrap`. + +- **web (Next.js UI)** + - сторінка `/city/[slug]`, + - компонент `ChatRoom`, + - Matrix chat client. + +--- + +## 2. AUTH MODEL + +### Допущення (MVP): + +- Користувач уже залогінений у DAARION (JWT). +- Для кожного `user_id` вже існує Matrix-акаунт (авто-provisioning реалізовано раніше). +- Потрібен **bootstrap endpoint**, який: + - по JWT → знаходить Matrix user, + - видає Matrix access token, + - повертає `matrix_room_id` для кімнати. + +### Matrix User Mapping + +| DAARION user_id | Matrix user_id | +|-----------------|----------------| +| `87838688-d7c4-436c-...` | `@daarion_87838688:daarion.space` | + +--- + +## 3. BACKEND: CHAT BOOTSTRAP API + +### 3.1. Endpoint: `GET /api/city/chat/bootstrap` + +**Розташування:** `city-service` (логічно відповідає за City+Matrix інтеграцію) + +**Вхід:** +- HTTP заголовок `Authorization: Bearer ` (DAARION JWT) +- query param: `room_slug`, наприклад `energy` + +**Логіка:** + +1. Валідувати JWT → отримати `user_id`. +2. Знайти `city_room` по `slug`: + - витягнути `matrix_room_id` / `matrix_room_alias`. +3. Через internal виклик до `matrix-gateway`: + - отримати Matrix access token для цього `user_id`. +4. Повернути фронтенду: + +```json +{ + "matrix_hs_url": "https://app.daarion.space", + "matrix_user_id": "@daarion_87838688:daarion.space", + "matrix_access_token": "syt_...", + "matrix_room_id": "!gykdLyazhkcSZGHmbG:daarion.space", + "matrix_room_alias": "#city_energy:daarion.space", + "room": { + "id": "room_city_energy", + "slug": "energy", + "name": "Energy" + } +} +``` + +### 3.2. Matrix Gateway: User Token Endpoint + +**Endpoint:** `POST /internal/matrix/users/token` + +**Request:** +```json +{ + "user_id": "87838688-d7c4-436c-9466-4ab0947d7730" +} +``` + +**Response:** +```json +{ + "matrix_user_id": "@daarion_87838688:daarion.space", + "access_token": "syt_...", + "device_id": "DEVICE_ID" +} +``` + +**Логіка:** +1. Побудувати Matrix username: `daarion_{user_id[:8]}` +2. Спробувати логін з відомим паролем +3. Якщо користувач не існує — створити через admin API +4. Повернути access token + +### 3.3. Security + +* Endpoint вимагає валідний DAARION JWT. +* `matrix_access_token` — короткоживучий (30 хв) або session-based. +* Internal endpoints (`/internal/*`) доступні тільки з Docker network. + +--- + +## 4. FRONTEND: MATRIX CHAT CLIENT + +### 4.1. Поточний Chat Layout + +Вже існує: +* сторінка `/city/[slug]`, +* компонент `ChatRoom`: + * `messages[]`, + * `onSend(message)`, + * індикатор підключення. + +Зараз він працює через свій WebSocket/stub. + +### 4.2. Нова схема + +1. **При завантаженні сторінки:** + ```tsx + // /city/[slug]/page.tsx + const [bootstrap, setBootstrap] = useState(null); + const [status, setStatus] = useState<'loading' | 'connecting' | 'online' | 'error'>('loading'); + + useEffect(() => { + async function init() { + // 1. Отримати bootstrap дані + const res = await fetch(`/api/city/chat/bootstrap?room_slug=${slug}`, { + headers: { Authorization: `Bearer ${token}` } + }); + const data = await res.json(); + setBootstrap(data); + + // 2. Ініціалізувати Matrix client + setStatus('connecting'); + } + init(); + }, [slug]); + ``` + +2. **Створення Matrix клієнта:** + ```tsx + // Використовуємо REST API напряму (без matrix-js-sdk для простоти MVP) + const matrixClient = new MatrixRestClient({ + baseUrl: bootstrap.matrix_hs_url, + accessToken: bootstrap.matrix_access_token, + userId: bootstrap.matrix_user_id, + roomId: bootstrap.matrix_room_id + }); + ``` + +3. **Отримання історії:** + ```tsx + const messages = await matrixClient.getMessages(roomId, { limit: 50 }); + ``` + +4. **Відправка повідомлень:** + ```tsx + await matrixClient.sendMessage(roomId, { + msgtype: 'm.text', + body: text + }); + ``` + +5. **Підписка на нові повідомлення:** + ```tsx + // Long-polling або sync + matrixClient.onMessage((event) => { + setMessages(prev => [...prev, mapMatrixEvent(event)]); + }); + ``` + +### 4.3. Matrix Event → Chat Message Mapping + +```tsx +function mapMatrixEvent(event: MatrixEvent): ChatMessage { + return { + id: event.event_id, + senderId: event.sender, + senderName: event.sender.split(':')[0].replace('@daarion_', 'User '), + text: event.content.body, + timestamp: new Date(event.origin_server_ts), + isUser: event.sender === bootstrap.matrix_user_id, + }; +} +``` + +--- + +## 5. MATRIX REST CLIENT (Lightweight) + +Замість важкого `matrix-js-sdk`, створимо легкий REST клієнт: + +```typescript +// lib/matrix-client.ts + +export class MatrixRestClient { + private baseUrl: string; + private accessToken: string; + private userId: string; + + constructor(config: MatrixClientConfig) { + this.baseUrl = config.baseUrl; + this.accessToken = config.accessToken; + this.userId = config.userId; + } + + // Get room messages + async getMessages(roomId: string, options?: { limit?: number; from?: string }) { + const params = new URLSearchParams({ + dir: 'b', + limit: String(options?.limit || 50) + }); + if (options?.from) params.set('from', options.from); + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${params}`, + { headers: this.authHeaders() } + ); + return res.json(); + } + + // Send text message + async sendMessage(roomId: string, body: string) { + const txnId = `m${Date.now()}`; + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + { + method: 'PUT', + headers: this.authHeaders(), + body: JSON.stringify({ + msgtype: 'm.text', + body: body + }) + } + ); + return res.json(); + } + + // Join room + async joinRoom(roomId: string) { + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, + { + method: 'POST', + headers: this.authHeaders() + } + ); + return res.json(); + } + + // Sync (for real-time updates) + async sync(since?: string) { + const params = new URLSearchParams({ timeout: '30000' }); + if (since) params.set('since', since); + + const res = await fetch( + `${this.baseUrl}/_matrix/client/v3/sync?${params}`, + { headers: this.authHeaders() } + ); + return res.json(); + } + + private authHeaders() { + return { + 'Authorization': `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json' + }; + } +} +``` + +--- + +## 6. UI / UX REQUIREMENTS + +### 6.1. Стан підключення + +| Status | UI | +|--------|-----| +| `loading` | Skeleton loader | +| `connecting` | "Підключення до Matrix…" + spinner | +| `online` | Зелений індикатор "Онлайн" | +| `error` | Червоний індикатор + "Помилка підключення" + кнопка "Повторити" | + +### 6.2. Відображення історії + +* При завантаженні показувати останні 50 повідомлень +* Infinite scroll для старіших повідомлень +* Показувати дату-роздільник між днями + +### 6.3. Надсилання повідомлень + +* Enter — відправити +* Shift+Enter — новий рядок +* Показувати "sending..." стан +* При помилці — показати "Не вдалося відправити" + retry + +--- + +## 7. LIMITATIONS / MVP + +Поки що: +* ✅ Тільки текстові повідомлення (`m.text`) +* ❌ Без файлів/зображень +* ❌ Без threads/reactions +* ❌ Без read receipts +* ❌ Без typing indicators + +Це все буде додано у наступних фазах. + +--- + +## 8. API SUMMARY + +### City Service (7001) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/city/chat/bootstrap?room_slug=X` | Bootstrap Matrix chat | + +### Matrix Gateway (7025) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/internal/matrix/users/token` | Get/create user token | + +--- + +## 9. ROADMAP AFTER THIS + +Після Matrix Chat Client: + +1. **Presence & Typing:** + * слухати `m.presence`, `m.typing` → показувати "online/typing". + +2. **Reactions & read receipts.** + +3. **Attachments (фото/файли).** + +4. **City Map інтеграція** (активність кімнат → візуалізація). + +--- + +## 10. ACCEPTANCE CRITERIA + +- [ ] `/api/city/chat/bootstrap` повертає Matrix credentials для авторизованого користувача +- [ ] Frontend підключається до Matrix і показує історію повідомлень +- [ ] Користувач може надсилати повідомлення через DAARION UI +- [ ] Повідомлення з'являються в Element Web і навпаки +- [ ] Обробляються стани: loading, connecting, online, error + diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index 07b8f02c..5659e495 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -2,9 +2,11 @@ City Backend API Routes """ -from fastapi import APIRouter, HTTPException, Depends, Body +from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query from typing import List, Optional import logging +import httpx +import os from models_city import ( CityRoomRead, @@ -20,6 +22,10 @@ from matrix_client import create_matrix_room, find_matrix_room_by_alias logger = logging.getLogger(__name__) +# JWT validation (simplified for MVP) +AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020") +MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025") + router = APIRouter(prefix="/city", tags=["city"]) @@ -293,6 +299,101 @@ async def backfill_matrix_rooms(): raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}") +# ============================================================================= +# Chat Bootstrap API (Matrix Integration) +# ============================================================================= + +async def validate_jwt_token(authorization: str) -> Optional[dict]: + """Validate JWT token via auth-service introspect endpoint.""" + if not authorization or not authorization.startswith("Bearer "): + return None + + token = authorization.replace("Bearer ", "") + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.get( + f"{AUTH_SERVICE_URL}/api/auth/introspect", + headers={"Authorization": f"Bearer {token}"} + ) + if resp.status_code == 200: + return resp.json() + return None + except Exception as e: + logger.error(f"JWT validation error: {e}") + return None + + +@router.get("/chat/bootstrap") +async def chat_bootstrap( + room_slug: str = Query(..., description="City room slug"), + authorization: Optional[str] = Header(None) +): + """ + Bootstrap Matrix chat for a city room. + + Returns Matrix credentials and room info for the authenticated user. + """ + # Validate JWT + user_info = await validate_jwt_token(authorization) + if not user_info: + raise HTTPException(status_code=401, detail="Invalid or missing authorization token") + + user_id = user_info.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Invalid token: missing user_id") + + # Get room by slug + room = await repo_city.get_room_by_slug(room_slug) + if not room: + raise HTTPException(status_code=404, detail=f"Room '{room_slug}' not found") + + # Check if room has Matrix integration + matrix_room_id = room.get("matrix_room_id") + matrix_room_alias = room.get("matrix_room_alias") + + if not matrix_room_id: + raise HTTPException( + status_code=400, + detail="Room does not have Matrix integration. Run /city/matrix/backfill first." + ) + + # Get Matrix user token from matrix-gateway + async with httpx.AsyncClient(timeout=30.0) as client: + try: + token_resp = await client.post( + f"{MATRIX_GATEWAY_URL}/internal/matrix/users/token", + json={"user_id": user_id} + ) + + if token_resp.status_code != 200: + error = token_resp.json() + logger.error(f"Failed to get Matrix token: {error}") + raise HTTPException(status_code=500, detail="Failed to get Matrix credentials") + + matrix_creds = token_resp.json() + + except httpx.RequestError as e: + logger.error(f"Matrix gateway request error: {e}") + raise HTTPException(status_code=503, detail="Matrix service unavailable") + + # Return bootstrap data + return { + "matrix_hs_url": f"https://app.daarion.space", # Through nginx proxy + "matrix_user_id": matrix_creds["matrix_user_id"], + "matrix_access_token": matrix_creds["access_token"], + "matrix_device_id": matrix_creds["device_id"], + "matrix_room_id": matrix_room_id, + "matrix_room_alias": matrix_room_alias, + "room": { + "id": room["id"], + "slug": room["slug"], + "name": room["name"], + "description": room.get("description") + } + } + + # ============================================================================= # City Feed API # ============================================================================= diff --git a/services/matrix-gateway/main.py b/services/matrix-gateway/main.py index 2c20d043..9c2c0789 100644 --- a/services/matrix-gateway/main.py +++ b/services/matrix-gateway/main.py @@ -67,6 +67,17 @@ class HealthResponse(BaseModel): server_name: str +class UserTokenRequest(BaseModel): + user_id: str # DAARION user_id (UUID) + + +class UserTokenResponse(BaseModel): + matrix_user_id: str + access_token: str + device_id: str + home_server: str + + async def get_admin_token() -> str: """Get or create admin access token for Matrix operations.""" global _admin_token @@ -318,6 +329,105 @@ async def get_room_info(room_id: str): raise HTTPException(status_code=503, detail="Matrix unavailable") +@app.post("/internal/matrix/users/token", response_model=UserTokenResponse) +async def get_user_token(request: UserTokenRequest): + """ + Get or create Matrix access token for a DAARION user. + + This is used for chat bootstrap - allows frontend to connect to Matrix + on behalf of the user. + """ + # Generate Matrix username from DAARION user_id + user_id_short = request.user_id[:8].replace('-', '') + matrix_username = f"daarion_{user_id_short}" + matrix_user_id = f"@{matrix_username}:{settings.matrix_server_name}" + + # Generate password (deterministic, based on user_id + secret) + matrix_password = hashlib.sha256( + f"{request.user_id}:{settings.synapse_registration_secret}".encode() + ).hexdigest()[:32] + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + # Try to login first + login_resp = await client.post( + f"{settings.synapse_url}/_matrix/client/v3/login", + json={ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": matrix_username + }, + "password": matrix_password, + "device_id": f"DAARION_{user_id_short}", + "initial_device_display_name": "DAARION Web" + } + ) + + if login_resp.status_code == 200: + result = login_resp.json() + logger.info(f"Matrix user logged in: {matrix_user_id}") + return UserTokenResponse( + matrix_user_id=result["user_id"], + access_token=result["access_token"], + device_id=result.get("device_id", f"DAARION_{user_id_short}"), + home_server=settings.matrix_server_name + ) + + # User doesn't exist, create via admin API + logger.info(f"Creating Matrix user: {matrix_username}") + + # 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(matrix_username.encode('utf-8')) + mac.update(b"\x00") + mac.update(matrix_password.encode('utf-8')) + mac.update(b"\x00") + mac.update(b"notadmin") + + # Register user + register_resp = await client.post( + f"{settings.synapse_url}/_synapse/admin/v1/register", + json={ + "nonce": nonce, + "username": matrix_username, + "password": matrix_password, + "admin": False, + "mac": mac.hexdigest() + } + ) + + if register_resp.status_code == 200: + result = register_resp.json() + logger.info(f"Matrix user created: {result['user_id']}") + return UserTokenResponse( + matrix_user_id=result["user_id"], + access_token=result["access_token"], + device_id=result.get("device_id", f"DAARION_{user_id_short}"), + home_server=settings.matrix_server_name + ) + else: + error = register_resp.json() + logger.error(f"Failed to create Matrix user: {error}") + raise HTTPException(status_code=500, detail=f"Failed to create Matrix user: {error.get('error', 'Unknown')}") + + 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)