feat: Implement Matrix Chat Client

This commit is contained in:
Apple
2025-11-26 13:15:01 -08:00
parent 871812ef92
commit e9c04f6bcd
7 changed files with 1264 additions and 8 deletions

View File

@@ -1,9 +1,9 @@
import Link from 'next/link' 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 { api, CityRoom } from '@/lib/api'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { notFound } from 'next/navigation' 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 // Force dynamic rendering - don't prerender at build time
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -73,12 +73,21 @@ export default async function RoomPage({ params }: PageProps) {
{/* Chat Area */} {/* Chat Area */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="glass-panel h-[500px] sm:h-[600px] flex flex-col overflow-hidden"> <div className="glass-panel h-[500px] sm:h-[600px] flex flex-col overflow-hidden">
<ChatRoom <MatrixChatRoom roomSlug={room.slug} />
roomId={room.id}
roomSlug={room.slug}
initialMessages={[]}
/>
</div> </div>
{/* Matrix Room Info */}
{room.matrix_room_id && (
<div className="mt-4 glass-panel p-4">
<div className="flex items-center gap-2 text-sm">
<MessageCircle className="w-4 h-4 text-cyan-400" />
<span className="text-slate-400">Matrix Room:</span>
<code className="text-xs font-mono text-cyan-400 bg-slate-800/50 px-2 py-0.5 rounded">
{room.matrix_room_alias || room.matrix_room_id}
</code>
</div>
</div>
)}
</div> </div>
{/* Sidebar */} {/* Sidebar */}

View File

@@ -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<MatrixChatMessage[]>([])
const [status, setStatus] = useState<ConnectionStatus>('loading')
const [error, setError] = useState<string | null>(null)
const [bootstrap, setBootstrap] = useState<BootstrapData | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const matrixClient = useRef<MatrixRestClient | null>(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 (
<div className="flex flex-col h-full">
{/* Connection status */}
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium text-white">Matrix Chat</span>
{bootstrap?.matrix_room_alias && (
<span className="text-xs text-slate-500 font-mono">
{bootstrap.matrix_room_alias}
</span>
)}
</div>
<div className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-full text-xs',
status === 'loading' && 'bg-slate-500/20 text-slate-400',
status === 'connecting' && 'bg-amber-500/20 text-amber-400',
status === 'online' && 'bg-emerald-500/20 text-emerald-400',
status === 'error' && 'bg-red-500/20 text-red-400',
status === 'unauthenticated' && 'bg-amber-500/20 text-amber-400'
)}>
{status === 'loading' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Завантаження...</span>
</>
)}
{status === 'connecting' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Підключення до Matrix...</span>
</>
)}
{status === 'online' && (
<>
<Wifi className="w-3 h-3" />
<span>Онлайн</span>
</>
)}
{status === 'error' && (
<>
<WifiOff className="w-3 h-3" />
<span>Помилка</span>
</>
)}
{status === 'unauthenticated' && (
<>
<AlertCircle className="w-3 h-3" />
<span>Потрібен вхід</span>
</>
)}
</div>
</div>
{/* Error / Auth required message */}
{(status === 'error' || status === 'unauthenticated') && (
<div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
<span>
{status === 'unauthenticated'
? 'Увійдіть, щоб приєднатися до чату'
: error || 'Помилка підключення'
}
</span>
</div>
{status === 'error' && (
<button
onClick={handleRetry}
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-full transition-colors"
>
<RefreshCw className="w-3 h-3" />
Повторити
</button>
)}
</div>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4 space-y-1">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-16 h-16 mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{status === 'online'
? 'Поки що немає повідомлень'
: status === 'unauthenticated'
? 'Увійдіть для доступу до чату'
: 'Підключення до Matrix...'
}
</h3>
<p className="text-sm text-slate-400 max-w-sm">
{status === 'online'
? 'Будьте першим, хто напише в цій кімнаті! Ваше повідомлення синхронізується з Matrix.'
: status === 'unauthenticated'
? 'Для участі в чаті потрібна авторизація'
: 'Встановлюємо зʼєднання з Matrix сервером...'
}
</p>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={mapToLegacyFormat(message)}
isOwn={message.isUser}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input area */}
<ChatInput
onSend={handleSendMessage}
disabled={status !== 'online'}
placeholder={
status === 'online'
? 'Напишіть повідомлення...'
: status === 'unauthenticated'
? 'Увійдіть для надсилання повідомлень'
: 'Очікування підключення...'
}
/>
</div>
)
}

View File

@@ -26,6 +26,9 @@ export interface CityRoom {
created_by: string | null created_by: string | null
members_online: number members_online: number
last_event: string | null last_event: string | null
// Matrix integration
matrix_room_id: string | null
matrix_room_alias: string | null
} }
export interface SecondMeProfile { export interface SecondMeProfile {

View File

@@ -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<MatrixMessagesResponse> {
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<MatrixSyncResponse> {
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<void> {
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
});
}

View File

@@ -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 <access_token>` (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

View File

@@ -2,9 +2,11 @@
City Backend API Routes 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 from typing import List, Optional
import logging import logging
import httpx
import os
from models_city import ( from models_city import (
CityRoomRead, CityRoomRead,
@@ -20,6 +22,10 @@ from matrix_client import create_matrix_room, find_matrix_room_by_alias
logger = logging.getLogger(__name__) 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"]) 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)}") 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 # City Feed API
# ============================================================================= # =============================================================================

View File

@@ -67,6 +67,17 @@ class HealthResponse(BaseModel):
server_name: str 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: async def get_admin_token() -> str:
"""Get or create admin access token for Matrix operations.""" """Get or create admin access token for Matrix operations."""
global _admin_token global _admin_token
@@ -318,6 +329,105 @@ async def get_room_info(room_id: str):
raise HTTPException(status_code=503, detail="Matrix unavailable") 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=settings.port) uvicorn.run(app, host="0.0.0.0", port=settings.port)