refactor Notifications
This commit is contained in:
parent
05ec62595d
commit
b926597a48
118
app/api/rocket-chat/user-token/route.ts
Normal file
118
app/api/rocket-chat/user-token/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
150
hooks/use-rocketchat-calls.ts
Normal file
150
hooks/use-rocketchat-calls.ts
Normal file
@ -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<RocketChatCallListener | null>(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]);
|
||||
}
|
||||
331
lib/services/rocketchat-call-listener.ts
Normal file
331
lib/services/rocketchat-call-listener.ts
Normal file
@ -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<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectDelay = 3000;
|
||||
private isConnecting = false;
|
||||
private isConnected = false;
|
||||
private callHandlers: Set<CallEventHandler> = 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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user