feat: Implement Matrix Chat Client
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
305
apps/web/src/components/chat/MatrixChatRoom.tsx
Normal file
305
apps/web/src/components/chat/MatrixChatRoom.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
315
apps/web/src/lib/matrix-client.ts
Normal file
315
apps/web/src/lib/matrix-client.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
413
docs/matrix/MATRIX_CHAT_CLIENT_SPEC.md
Normal file
413
docs/matrix/MATRIX_CHAT_CLIENT_SPEC.md
Normal 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
|
||||||
|
|
||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user