From b926597a48822223f166a70a27ca55ce3c9c47f5 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 16 Jan 2026 00:43:43 +0100 Subject: [PATCH] refactor Notifications --- app/api/rocket-chat/user-token/route.ts | 118 ++++++++ components/layout/layout-wrapper.tsx | 4 + hooks/use-rocketchat-calls.ts | 150 ++++++++++ lib/services/rocketchat-call-listener.ts | 331 +++++++++++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 app/api/rocket-chat/user-token/route.ts create mode 100644 hooks/use-rocketchat-calls.ts create mode 100644 lib/services/rocketchat-call-listener.ts diff --git a/app/api/rocket-chat/user-token/route.ts b/app/api/rocket-chat/user-token/route.ts new file mode 100644 index 0000000..8ea8ff9 --- /dev/null +++ b/app/api/rocket-chat/user-token/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { logger } from '@/lib/logger'; + +/** + * Get RocketChat user token for WebSocket connection + * This endpoint returns the user's auth token and userId for real-time connections + */ +export async function GET(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + + const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0]; + if (!baseUrl) { + logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to get Rocket.Chat base URL'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + // Use admin token to authenticate + const adminHeaders = { + 'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!, + 'X-User-Id': process.env.ROCKET_CHAT_USER_ID!, + 'Content-Type': 'application/json' + }; + + // Get username from email + const username = session.user.email.split('@')[0]; + if (!username) { + logger.error('[ROCKET_CHAT_USER_TOKEN] No username found in session email'); + return NextResponse.json( + { error: 'Invalid user' }, + { status: 400 } + ); + } + + // Get all users to find the current user + const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, { + method: 'GET', + headers: adminHeaders + }); + + if (!usersResponse.ok) { + logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to get users list'); + return NextResponse.json( + { error: 'Failed to get user' }, + { status: 500 } + ); + } + + const usersData = await usersResponse.json(); + const currentUser = usersData.users?.find((u: any) => + u.username?.toLowerCase() === username.toLowerCase() || + u.emails?.some((e: any) => e.address?.toLowerCase() === session.user.email?.toLowerCase()) + ); + + if (!currentUser) { + logger.error('[ROCKET_CHAT_USER_TOKEN] User not found in RocketChat', { username }); + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + // Create user token + const secret = process.env.ROCKET_CHAT_CREATE_TOKEN_SECRET; + if (!secret) { + logger.error('[ROCKET_CHAT_USER_TOKEN] ROCKET_CHAT_CREATE_TOKEN_SECRET not configured'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, { + method: 'POST', + headers: adminHeaders, + body: JSON.stringify({ + userId: currentUser._id, + secret: secret + }) + }); + + if (!createTokenResponse.ok) { + logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to create user token'); + return NextResponse.json( + { error: 'Failed to create token' }, + { status: 500 } + ); + } + + const tokenData = await createTokenResponse.json(); + + return NextResponse.json({ + userId: currentUser._id, + authToken: tokenData.data.authToken, + username: currentUser.username, + }); + } catch (error: any) { + logger.error('[ROCKET_CHAT_USER_TOKEN] Error', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Internal server error", message: error.message }, + { status: 500 } + ); + } +} diff --git a/components/layout/layout-wrapper.tsx b/components/layout/layout-wrapper.tsx index 99b11f1..59dcfe0 100644 --- a/components/layout/layout-wrapper.tsx +++ b/components/layout/layout-wrapper.tsx @@ -8,6 +8,7 @@ import { AuthCheck } from "@/components/auth/auth-check"; import { Toaster } from "@/components/ui/toaster"; import { useBackgroundImage } from "@/components/background-switcher"; import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; +import { useRocketChatCalls } from "@/hooks/use-rocketchat-calls"; interface LayoutWrapperProps { children: React.ReactNode; @@ -18,6 +19,9 @@ interface LayoutWrapperProps { export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: LayoutWrapperProps) { const { currentBackground, changeBackground } = useBackgroundImage(); const { data: session } = useSession(); + + // Listen for incoming RocketChat calls + useRocketChatCalls(); // Global listener for logout messages from iframe applications useEffect(() => { diff --git a/hooks/use-rocketchat-calls.ts b/hooks/use-rocketchat-calls.ts new file mode 100644 index 0000000..1471e3f --- /dev/null +++ b/hooks/use-rocketchat-calls.ts @@ -0,0 +1,150 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useSession } from 'next-auth/react'; +import { RocketChatCallListener, CallEvent } from '@/lib/services/rocketchat-call-listener'; +import { useWidgetNotification } from './use-widget-notification'; +import { logger } from '@/lib/logger'; + +/** + * Hook to listen for incoming RocketChat calls and trigger notifications + */ +export function useRocketChatCalls() { + const { data: session } = useSession(); + const { triggerNotification } = useWidgetNotification(); + const listenerRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + const initializedRef = useRef(false); + + /** + * Get RocketChat credentials for the current user + */ + const getRocketChatCredentials = useCallback(async () => { + if (!session?.user?.id) { + return null; + } + + try { + // Get user token from RocketChat API + const response = await fetch('/api/rocket-chat/messages?refresh=true', { + credentials: 'include', + }); + + if (!response.ok) { + logger.error('[useRocketChatCalls] Failed to get RocketChat credentials'); + return null; + } + + // Extract base URL from environment + const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0]; + if (!baseUrl) { + logger.error('[useRocketChatCalls] RocketChat base URL not configured'); + return null; + } + + // Get user's RocketChat ID and token + // We need to call the API to get the user token + const tokenResponse = await fetch('/api/rocket-chat/user-token', { + credentials: 'include', + }); + + if (!tokenResponse.ok) { + logger.error('[useRocketChatCalls] Failed to get user token'); + return null; + } + + const tokenData = await tokenResponse.json(); + return { + userId: tokenData.userId, + authToken: tokenData.authToken, + baseUrl, + }; + } catch (error) { + logger.error('[useRocketChatCalls] Error getting credentials', { error }); + return null; + } + }, [session?.user?.id]); + + /** + * Initialize call listener + */ + const initializeListener = useCallback(async () => { + if (initializedRef.current || listenerRef.current) { + return; + } + + const credentials = await getRocketChatCredentials(); + if (!credentials) { + logger.warn('[useRocketChatCalls] Could not get credentials, skipping initialization'); + return; + } + + try { + const listener = RocketChatCallListener.getInstance(); + const success = await listener.initialize( + credentials.userId, + credentials.authToken, + credentials.baseUrl + ); + + if (success) { + listenerRef.current = listener; + initializedRef.current = true; + + // Subscribe to call events + const unsubscribe = listener.onCall((callEvent: CallEvent) => { + logger.info('[useRocketChatCalls] Incoming call detected', { + from: callEvent.from.username, + roomId: callEvent.roomId, + }); + + // Trigger notification + triggerNotification({ + source: 'rocketchat', + count: 1, // Increment count for call + items: [ + { + id: `call-${callEvent.roomId}-${Date.now()}`, + title: `Appel entrant de ${callEvent.from.name || callEvent.from.username}`, + message: `Appel vidéo/audio dans ${callEvent.roomName || 'chat'}`, + link: `/parole?room=${callEvent.roomId}`, + timestamp: callEvent.timestamp, + metadata: { + type: 'call', + from: callEvent.from, + roomId: callEvent.roomId, + }, + }, + ], + }); + }); + + unsubscribeRef.current = unsubscribe; + logger.debug('[useRocketChatCalls] Call listener initialized'); + } + } catch (error) { + logger.error('[useRocketChatCalls] Error initializing listener', { error }); + } + }, [getRocketChatCredentials, triggerNotification]); + + /** + * Cleanup on unmount + */ + useEffect(() => { + if (session?.user?.id) { + initializeListener(); + } + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + + if (listenerRef.current) { + listenerRef.current.disconnect(); + listenerRef.current = null; + } + + initializedRef.current = false; + }; + }, [session?.user?.id, initializeListener]); +} diff --git a/lib/services/rocketchat-call-listener.ts b/lib/services/rocketchat-call-listener.ts new file mode 100644 index 0000000..ae58bcb --- /dev/null +++ b/lib/services/rocketchat-call-listener.ts @@ -0,0 +1,331 @@ +/** + * RocketChat Call Listener Service + * + * Listens for incoming calls via RocketChat's DDP/WebSocket real-time API + * Uses stream-notify-user with webrtc events to detect incoming calls + */ + +import { logger } from '@/lib/logger'; + +export interface CallEvent { + type: 'call-incoming' | 'call-answered' | 'call-ended'; + from: { + userId: string; + username: string; + name?: string; + }; + roomId: string; + roomName?: string; + timestamp: Date; +} + +type CallEventHandler = (event: CallEvent) => void; + +export class RocketChatCallListener { + private static instance: RocketChatCallListener; + private ws: WebSocket | null = null; + private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private reconnectDelay = 3000; + private isConnecting = false; + private isConnected = false; + private callHandlers: Set = new Set(); + private userId: string | null = null; + private authToken: string | null = null; + private baseUrl: string | null = null; + private subscriptionId: string | null = null; + + private constructor() {} + + public static getInstance(): RocketChatCallListener { + if (!RocketChatCallListener.instance) { + RocketChatCallListener.instance = new RocketChatCallListener(); + } + return RocketChatCallListener.instance; + } + + /** + * Initialize the listener with user credentials + */ + public async initialize(userId: string, authToken: string, baseUrl: string): Promise { + this.userId = userId; + this.authToken = authToken; + this.baseUrl = baseUrl; + + if (this.isConnected || this.isConnecting) { + logger.debug('[ROCKETCHAT_CALL_LISTENER] Already connected or connecting'); + return true; + } + + return this.connect(); + } + + /** + * Connect to RocketChat WebSocket + */ + private async connect(): Promise { + if (this.isConnecting) { + return false; + } + + if (!this.baseUrl || !this.userId || !this.authToken) { + logger.error('[ROCKETCHAT_CALL_LISTENER] Missing credentials for connection'); + return false; + } + + this.isConnecting = true; + + try { + // Convert HTTP URL to WebSocket URL + const wsUrl = this.baseUrl + .replace('http://', 'ws://') + .replace('https://', 'wss://') + .replace(/\/$/, '') + '/websocket'; + + logger.debug('[ROCKETCHAT_CALL_LISTENER] Connecting to WebSocket', { wsUrl }); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + logger.debug('[ROCKETCHAT_CALL_LISTENER] WebSocket connected'); + this.isConnecting = false; + this.isConnected = true; + this.reconnectAttempts = 0; + this.authenticate(); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(JSON.parse(event.data)); + }; + + this.ws.onerror = (error) => { + logger.error('[ROCKETCHAT_CALL_LISTENER] WebSocket error', { error }); + this.isConnecting = false; + this.isConnected = false; + }; + + this.ws.onclose = () => { + logger.debug('[ROCKETCHAT_CALL_LISTENER] WebSocket closed'); + this.isConnected = false; + this.isConnecting = false; + this.subscriptionId = null; + this.attemptReconnect(); + }; + + return true; + } catch (error) { + logger.error('[ROCKETCHAT_CALL_LISTENER] Connection error', { + error: error instanceof Error ? error.message : String(error), + }); + this.isConnecting = false; + this.attemptReconnect(); + return false; + } + } + + /** + * Authenticate with RocketChat + */ + private authenticate(): void { + if (!this.ws || !this.authToken || !this.userId) { + return; + } + + const loginMessage = { + msg: 'method', + method: 'login', + id: `login-${Date.now()}`, + params: [ + { + resume: this.authToken, + }, + ], + }; + + logger.debug('[ROCKETCHAT_CALL_LISTENER] Sending login message'); + this.ws.send(JSON.stringify(loginMessage)); + } + + /** + * Subscribe to webrtc events for incoming calls + */ + private subscribeToCalls(): void { + if (!this.ws || !this.userId) { + return; + } + + this.subscriptionId = `call-sub-${Date.now()}`; + + const subscribeMessage = { + msg: 'sub', + id: this.subscriptionId, + name: 'stream-notify-user', + params: [`${this.userId}/webrtc`, false], + }; + + logger.debug('[ROCKETCHAT_CALL_LISTENER] Subscribing to webrtc events', { + subscriptionId: this.subscriptionId, + userId: this.userId, + }); + + this.ws.send(JSON.stringify(subscribeMessage)); + } + + /** + * Handle incoming WebSocket messages + */ + private handleMessage(message: any): void { + // Handle login response + if (message.msg === 'result' && message.id?.startsWith('login-')) { + if (message.result?.token) { + logger.debug('[ROCKETCHAT_CALL_LISTENER] Login successful'); + this.subscribeToCalls(); + } else { + logger.error('[ROCKETCHAT_CALL_LISTENER] Login failed', { message }); + } + return; + } + + // Handle subscription ready + if (message.msg === 'ready' && message.subs?.includes(this.subscriptionId)) { + logger.debug('[ROCKETCHAT_CALL_LISTENER] Subscription ready'); + return; + } + + // Handle call events (webrtc) + if (message.msg === 'changed' && message.collection === 'stream-notify-user') { + const eventName = message.fields?.eventName; + const args = message.fields?.args || []; + + if (eventName?.includes('/webrtc') && args.length > 0) { + this.handleCallEvent(args[0]); + } + } + } + + /** + * Handle call event from RocketChat + */ + private handleCallEvent(eventData: any): void { + logger.debug('[ROCKETCHAT_CALL_LISTENER] Received call event', { eventData }); + + try { + // Parse the call event data + // RocketChat webrtc events typically contain: + // - type: 'call', 'ringing', 'answered', 'ended' + // - from: user info + // - roomId: room identifier + const callType = eventData.type || eventData.action; + const from = eventData.from || eventData.caller || {}; + const roomId = eventData.roomId || eventData.rid; + + if (!callType || !roomId) { + logger.warn('[ROCKETCHAT_CALL_LISTENER] Invalid call event data', { eventData }); + return; + } + + // Detect incoming call + if (callType === 'call' || callType === 'ringing' || eventData.action === 'ringing') { + const callEvent: CallEvent = { + type: 'call-incoming', + from: { + userId: from._id || from.userId || '', + username: from.username || '', + name: from.name || from.username, + }, + roomId, + roomName: eventData.roomName || from.name, + timestamp: new Date(), + }; + + logger.info('[ROCKETCHAT_CALL_LISTENER] Incoming call detected', { + from: callEvent.from.username, + roomId, + }); + + // Notify all handlers + this.callHandlers.forEach((handler) => { + try { + handler(callEvent); + } catch (error) { + logger.error('[ROCKETCHAT_CALL_LISTENER] Error in call handler', { error }); + } + }); + } + } catch (error) { + logger.error('[ROCKETCHAT_CALL_LISTENER] Error handling call event', { + error: error instanceof Error ? error.message : String(error), + eventData, + }); + } + } + + /** + * Register a handler for call events + */ + public onCall(handler: CallEventHandler): () => void { + this.callHandlers.add(handler); + + // Return unsubscribe function + return () => { + this.callHandlers.delete(handler); + }; + } + + /** + * Attempt to reconnect + */ + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + logger.error('[ROCKETCHAT_CALL_LISTENER] Max reconnect attempts reached'); + return; + } + + if (this.reconnectTimeout) { + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * this.reconnectAttempts; + + logger.debug('[ROCKETCHAT_CALL_LISTENER] Scheduling reconnect', { + attempt: this.reconnectAttempts, + delay, + }); + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.connect(); + }, delay); + } + + /** + * Disconnect and cleanup + */ + public disconnect(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.isConnected = false; + this.isConnecting = false; + this.subscriptionId = null; + this.callHandlers.clear(); + + logger.debug('[ROCKETCHAT_CALL_LISTENER] Disconnected'); + } + + /** + * Check if connected + */ + public get connected(): boolean { + return this.isConnected; + } +}