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)