mirror of
https://github.com/AeThex-Corporation/AeThex-OS.git
synced 2026-04-17 22:27:19 +00:00
- ModuleManager: Central tracking for installed marketplace modules - DataAnalyzerWidget: Real-time CPU/RAM/Battery/Storage widget (unlocked by Data Analyzer module) - BottomNavBar: Navigation bar for Projects/Chat/Marketplace/Settings - RootShell: Real root command execution utility - TerminalActivity: Full root shell with neofetch, sysinfo, real Linux commands - Terminal Pro module: Adds aliases (ll, la, h), command history - ArcadeActivity + SnakeGame: Pixel Arcade module unlocks retro games - fade_in/fade_out animations for smooth transitions
471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
/**
|
|
* Call Routes
|
|
* API endpoints for voice/video calls
|
|
* Ported from AeThex-Connect
|
|
*/
|
|
|
|
import { Router, Request, Response } from "express";
|
|
import { supabase } from "./supabase.js";
|
|
import { requireAuth } from "./auth.js";
|
|
import crypto from "crypto";
|
|
|
|
const router = Router();
|
|
|
|
// Helper to get user ID from session
|
|
function getUserId(req: Request): string | null {
|
|
return (req.session as any)?.userId || null;
|
|
}
|
|
|
|
// ==================== CALL ROUTES ====================
|
|
|
|
/**
|
|
* POST /api/calls/initiate - Start a new call
|
|
*/
|
|
router.post("/initiate", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { conversationId, type, participants } = req.body;
|
|
|
|
if (!conversationId || !type) {
|
|
return res.status(400).json({ error: "conversationId and type are required" });
|
|
}
|
|
|
|
if (!["voice", "video"].includes(type)) {
|
|
return res.status(400).json({ error: "type must be 'voice' or 'video'" });
|
|
}
|
|
|
|
// Verify user is participant in conversation
|
|
const { data: participant } = await supabase
|
|
.from("conversation_participants")
|
|
.select("*")
|
|
.eq("conversation_id", conversationId)
|
|
.eq("user_id", userId)
|
|
.single();
|
|
|
|
if (!participant) {
|
|
return res.status(403).json({ error: "Not a participant in this conversation" });
|
|
}
|
|
|
|
// Get conversation details
|
|
const { data: conversation, error: convError } = await supabase
|
|
.from("conversations")
|
|
.select("*")
|
|
.eq("id", conversationId)
|
|
.single();
|
|
|
|
if (convError || !conversation) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
|
|
// Determine if group call
|
|
const isGroupCall = (participants && participants.length > 1) || conversation.type === "group";
|
|
const sfuRoomId = isGroupCall ? `room-${crypto.randomUUID()}` : null;
|
|
|
|
// Create call record
|
|
const { data: call, error: callError } = await supabase
|
|
.from("calls")
|
|
.insert({
|
|
conversation_id: conversationId,
|
|
type,
|
|
initiator_id: userId,
|
|
status: "ringing",
|
|
sfu_room_id: sfuRoomId
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (callError) throw callError;
|
|
|
|
// Add initiator as participant
|
|
await supabase
|
|
.from("call_participants")
|
|
.insert({
|
|
call_id: call.id,
|
|
user_id: userId,
|
|
joined_at: new Date().toISOString()
|
|
});
|
|
|
|
// Get target participants (either provided or from conversation)
|
|
let targetParticipants: string[] = participants || [];
|
|
if (targetParticipants.length === 0) {
|
|
const { data: convParticipants } = await supabase
|
|
.from("conversation_participants")
|
|
.select("user_id")
|
|
.eq("conversation_id", conversationId)
|
|
.neq("user_id", userId);
|
|
|
|
targetParticipants = (convParticipants || []).map(p => p.user_id);
|
|
}
|
|
|
|
// Add target participants with ringing status
|
|
for (const targetId of targetParticipants) {
|
|
await supabase
|
|
.from("call_participants")
|
|
.insert({
|
|
call_id: call.id,
|
|
user_id: targetId
|
|
});
|
|
}
|
|
|
|
// Generate TURN credentials
|
|
const turnCredentials = await generateTURNCredentials(userId);
|
|
|
|
res.json({
|
|
call: {
|
|
id: call.id,
|
|
conversationId,
|
|
type,
|
|
status: "ringing",
|
|
initiatorId: userId,
|
|
isGroupCall,
|
|
sfuRoomId,
|
|
participants: targetParticipants.map(id => ({ userId: id, status: "ringing" })),
|
|
turnCredentials,
|
|
createdAt: call.created_at
|
|
}
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[Calls] Initiate error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/calls/:callId/answer - Answer incoming call
|
|
*/
|
|
router.post("/:callId/answer", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { callId } = req.params;
|
|
const { accept } = req.body;
|
|
|
|
// Get call
|
|
const { data: call, error: callError } = await supabase
|
|
.from("calls")
|
|
.select("*")
|
|
.eq("id", callId)
|
|
.single();
|
|
|
|
if (callError || !call) {
|
|
return res.status(404).json({ error: "Call not found" });
|
|
}
|
|
|
|
if (!accept) {
|
|
// Reject call
|
|
await endCall(callId, userId, "rejected");
|
|
return res.json({
|
|
call: { id: callId, status: "ended", endReason: "rejected" }
|
|
});
|
|
}
|
|
|
|
// Accept call - update participant
|
|
await supabase
|
|
.from("call_participants")
|
|
.update({ joined_at: new Date().toISOString() })
|
|
.eq("call_id", callId)
|
|
.eq("user_id", userId);
|
|
|
|
// Mark call as active for 1-on-1 calls
|
|
if (!call.sfu_room_id) {
|
|
await supabase
|
|
.from("calls")
|
|
.update({
|
|
status: "active",
|
|
started_at: new Date().toISOString()
|
|
})
|
|
.eq("id", callId);
|
|
}
|
|
|
|
// Generate TURN credentials
|
|
const turnCredentials = await generateTURNCredentials(userId);
|
|
|
|
const response: any = {
|
|
call: {
|
|
id: callId,
|
|
status: "active",
|
|
turnCredentials
|
|
}
|
|
};
|
|
|
|
// Include SFU config for group calls
|
|
if (call.sfu_room_id) {
|
|
response.call.sfuConfig = {
|
|
roomId: call.sfu_room_id,
|
|
routerRtpCapabilities: {
|
|
codecs: [
|
|
{ kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2 },
|
|
{ kind: "video", mimeType: "video/VP8", clockRate: 90000 },
|
|
{ kind: "video", mimeType: "video/VP9", clockRate: 90000 }
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
res.json(response);
|
|
} catch (err: any) {
|
|
console.error("[Calls] Answer error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/calls/:callId/reject - Reject incoming call
|
|
*/
|
|
router.post("/:callId/reject", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { callId } = req.params;
|
|
const result = await endCall(callId, userId, "rejected");
|
|
|
|
res.json({ call: result });
|
|
} catch (err: any) {
|
|
console.error("[Calls] Reject error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/calls/:callId/end - End active call
|
|
*/
|
|
router.post("/:callId/end", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { callId } = req.params;
|
|
const result = await endCall(callId, userId, "hangup");
|
|
|
|
res.json({ call: result });
|
|
} catch (err: any) {
|
|
console.error("[Calls] End error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/calls/:callId/media - Update media state
|
|
*/
|
|
router.patch("/:callId/media", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { callId } = req.params;
|
|
const mediaState = req.body;
|
|
|
|
await supabase
|
|
.from("call_participants")
|
|
.update({ media_state: mediaState })
|
|
.eq("call_id", callId)
|
|
.eq("user_id", userId);
|
|
|
|
res.json({ mediaState });
|
|
} catch (err: any) {
|
|
console.error("[Calls] Media update error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/calls/turn-credentials - Get TURN server credentials
|
|
*/
|
|
router.get("/turn-credentials", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const credentials = await generateTURNCredentials(userId);
|
|
res.json({ credentials });
|
|
} catch (err: any) {
|
|
console.error("[Calls] TURN credentials error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/calls/:callId - Get call details
|
|
*/
|
|
router.get("/:callId", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { callId } = req.params;
|
|
|
|
// Get call with participants
|
|
const { data: call, error: callError } = await supabase
|
|
.from("calls")
|
|
.select("*")
|
|
.eq("id", callId)
|
|
.single();
|
|
|
|
if (callError || !call) {
|
|
return res.status(404).json({ error: "Call not found" });
|
|
}
|
|
|
|
// Get participants
|
|
const { data: participants } = await supabase
|
|
.from("call_participants")
|
|
.select("*")
|
|
.eq("call_id", callId);
|
|
|
|
res.json({
|
|
call: {
|
|
...call,
|
|
participants: participants || []
|
|
}
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[Calls] Get call error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/calls - Get user's recent calls
|
|
*/
|
|
router.get("/", requireAuth, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
|
|
// Get calls where user was a participant
|
|
const { data: participantData } = await supabase
|
|
.from("call_participants")
|
|
.select("call_id")
|
|
.eq("user_id", userId);
|
|
|
|
const callIds = (participantData || []).map(p => p.call_id);
|
|
|
|
if (callIds.length === 0) {
|
|
return res.json({ calls: [] });
|
|
}
|
|
|
|
const { data: calls, error } = await supabase
|
|
.from("calls")
|
|
.select("*")
|
|
.in("id", callIds)
|
|
.order("created_at", { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ calls: calls || [] });
|
|
} catch (err: any) {
|
|
console.error("[Calls] List error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ==================== HELPER FUNCTIONS ====================
|
|
|
|
/**
|
|
* End a call
|
|
*/
|
|
async function endCall(callId: string, userId: string, reason: string) {
|
|
// Get call
|
|
const { data: call, error } = await supabase
|
|
.from("calls")
|
|
.select("*")
|
|
.eq("id", callId)
|
|
.single();
|
|
|
|
if (error || !call) {
|
|
throw new Error("Call not found");
|
|
}
|
|
|
|
// Calculate duration
|
|
let duration = null;
|
|
if (call.started_at) {
|
|
const now = new Date();
|
|
const started = new Date(call.started_at);
|
|
duration = Math.floor((now.getTime() - started.getTime()) / 1000);
|
|
}
|
|
|
|
// Update call
|
|
await supabase
|
|
.from("calls")
|
|
.update({
|
|
status: "ended",
|
|
ended_at: new Date().toISOString(),
|
|
duration_seconds: duration,
|
|
end_reason: reason
|
|
})
|
|
.eq("id", callId);
|
|
|
|
// Update participant
|
|
await supabase
|
|
.from("call_participants")
|
|
.update({ left_at: new Date().toISOString() })
|
|
.eq("call_id", callId)
|
|
.eq("user_id", userId);
|
|
|
|
return {
|
|
id: callId,
|
|
status: "ended",
|
|
duration,
|
|
endedBy: userId,
|
|
reason
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate TURN server credentials
|
|
*/
|
|
async function generateTURNCredentials(userId: string) {
|
|
const turnSecret = process.env.TURN_SECRET;
|
|
const turnHost = process.env.TURN_SERVER_HOST || "turn.aethex.network";
|
|
const turnPort = process.env.TURN_SERVER_PORT || "3478";
|
|
|
|
// If no TURN secret configured, return public STUN servers only
|
|
if (!turnSecret) {
|
|
return {
|
|
urls: [
|
|
"stun:stun.l.google.com:19302",
|
|
"stun:stun1.l.google.com:19302"
|
|
],
|
|
username: null,
|
|
credential: null,
|
|
ttl: 86400
|
|
};
|
|
}
|
|
|
|
const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL
|
|
const username = `${timestamp}:${userId}`;
|
|
|
|
// Generate credential using HMAC
|
|
const hmac = crypto.createHmac("sha1", turnSecret);
|
|
hmac.update(username);
|
|
const credential = hmac.digest("base64");
|
|
|
|
// Store in database
|
|
await supabase
|
|
.from("turn_credentials")
|
|
.upsert({
|
|
user_id: userId,
|
|
username,
|
|
credential,
|
|
expires_at: new Date(timestamp * 1000).toISOString()
|
|
}, { onConflict: "user_id" });
|
|
|
|
return {
|
|
urls: [
|
|
`stun:${turnHost}:${turnPort}`,
|
|
`turn:${turnHost}:${turnPort}`,
|
|
`turn:${turnHost}:${turnPort}?transport=tcp`
|
|
],
|
|
username,
|
|
credential,
|
|
ttl: 86400
|
|
};
|
|
}
|
|
|
|
export default router;
|