AeThex-OS/server/call-routes.ts
MrPiglr b3c308b2c8 Add functional marketplace modules, bottom nav bar, root terminal, arcade games
- 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
2026-02-18 22:03:50 -07:00

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;