d35c806d5b
- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability. - Improved type definitions in index.ts for better code understanding and usage. - Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service. - Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend. - Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker. - Included a .dockerignore file to exclude unnecessary files from Docker builds.
307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
// ── WebRTC Signaling Server ─────────────────────────────────────────
|
|
// Provides real-time signaling for the CRM chat system using Socket.IO:
|
|
// - JWT-based authentication middleware
|
|
// - Online user presence tracking (online/offline)
|
|
// - Peer-to-peer call signaling (offer/answer/ICE candidates)
|
|
// - Multi-participant room-based WebRTC (join/leave/relay)
|
|
// - Message deletion broadcast to conversation participants
|
|
//
|
|
// Clients authenticate via socket handshake auth token; unauthenticated
|
|
// users can still join rooms as anonymous participants.
|
|
|
|
import { createServer } from "http"
|
|
import { Server } from "socket.io"
|
|
import { SignJWT, jwtVerify } from "jose"
|
|
import pg from "pg"
|
|
|
|
// ── Configuration ─────────────────────────────────────────────────
|
|
const PORT = process.env.SIGNALING_PORT || 3007
|
|
const rawSecret = process.env.JWT_SECRET
|
|
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
|
|
const JWT_SECRET = new TextEncoder().encode(rawSecret)
|
|
const DATABASE_URL = process.env.DATABASE_URL
|
|
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
|
|
|
|
// ── Database pool ─────────────────────────────────────────────────
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
|
|
|
// ── HTTP + Socket.IO server ───────────────────────────────────────
|
|
const httpServer = createServer()
|
|
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
|
|
|
// ── In-memory state ───────────────────────────────────────────────
|
|
// onlineUsers maps userId -> socketId for presence tracking.
|
|
// rooms maps roomId -> array of { socketId, id, label } participants.
|
|
const onlineUsers = new Map()
|
|
const rooms = new Map()
|
|
|
|
// ── Authentication middleware ─────────────────────────────────────
|
|
// Verifies the JWT token from handshake auth. If valid, attaches
|
|
// userId and role to the socket's data bag. Unauthenticated sockets
|
|
// proceed with null user info.
|
|
io.use((socket, next) => {
|
|
const token = socket.handshake.auth?.token
|
|
if (token) {
|
|
jwtVerify(token, JWT_SECRET)
|
|
.then(({ payload }) => {
|
|
socket.data.userId = payload.userId
|
|
socket.data.role = payload.role
|
|
next()
|
|
})
|
|
.catch(() => {
|
|
socket.data.userId = null
|
|
socket.data.role = null
|
|
next()
|
|
})
|
|
} else {
|
|
socket.data.userId = null
|
|
socket.data.role = null
|
|
next()
|
|
}
|
|
})
|
|
|
|
// ── Connection handler ────────────────────────────────────────────
|
|
// Registers authenticated users as online, then wires up all
|
|
// per-socket event listeners for signaling and presence.
|
|
io.on("connection", (socket) => {
|
|
const userId = socket.data.userId
|
|
|
|
// Mark the user online and broadcast to other clients
|
|
if (userId) {
|
|
onlineUsers.set(userId, socket.id)
|
|
socket.broadcast.emit("user:online", { userId })
|
|
}
|
|
|
|
// ── Authenticated event handlers ──────────────────────────────
|
|
// Only users with a valid JWT token may use these features.
|
|
if (userId) {
|
|
|
|
// user:lookup — looks up a user by phone number in the database.
|
|
// Fires when the client searches for a contact to start a call.
|
|
// Returns { found, user } or { found: false }.
|
|
socket.on("user:lookup", async ({ phone }, callback) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT id, username, first_name, last_name, phone, avatar_url
|
|
FROM users
|
|
WHERE phone = $1 AND deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[phone],
|
|
)
|
|
if (result.rows.length > 0) {
|
|
const user = result.rows[0]
|
|
callback({
|
|
found: true,
|
|
user: {
|
|
id: user.id,
|
|
username: user.username,
|
|
firstName: user.first_name,
|
|
lastName: user.last_name,
|
|
phone: user.phone,
|
|
avatar: user.avatar_url,
|
|
online: onlineUsers.has(user.id),
|
|
},
|
|
})
|
|
} else {
|
|
callback({ found: false })
|
|
}
|
|
} catch {
|
|
callback({ found: false, error: "Lookup failed" })
|
|
}
|
|
})
|
|
|
|
// call:offer — relays an SDP offer to the target user.
|
|
// Fires when the caller initiates a WebRTC call.
|
|
// callerInfo is fetched from the DB if not supplied.
|
|
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
let info = callerInfo
|
|
if (!info) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT id, username, first_name, last_name, avatar_url
|
|
FROM users WHERE id = $1`,
|
|
[userId],
|
|
)
|
|
if (result.rows.length > 0) {
|
|
const u = result.rows[0]
|
|
info = {
|
|
id: u.id,
|
|
username: u.username,
|
|
firstName: u.first_name,
|
|
lastName: u.last_name,
|
|
avatar: u.avatar_url,
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
io.to(targetSocketId).emit("call:incoming", {
|
|
from: userId,
|
|
sdp,
|
|
callerInfo: info,
|
|
})
|
|
} else {
|
|
socket.emit("call:user-offline", { userId: to })
|
|
}
|
|
})
|
|
|
|
// call:answer — relays an SDP answer back to the caller.
|
|
// Fires when the callee accepts the incoming call.
|
|
socket.on("call:answer", ({ to, sdp }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
|
|
}
|
|
})
|
|
|
|
// call:ice-candidate — relays ICE candidates between peers.
|
|
// Fires during connection negotiation for NAT traversal.
|
|
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
|
|
}
|
|
})
|
|
|
|
// call:end — notifies the remote peer that the call ended.
|
|
socket.on("call:end", ({ to }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("call:ended", { from: userId })
|
|
}
|
|
})
|
|
|
|
// call:reject — tells the caller the callee rejected the call.
|
|
socket.on("call:reject", ({ to }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("call:rejected", { from: userId })
|
|
}
|
|
})
|
|
|
|
// call:busy — tells the caller the callee is on another call.
|
|
socket.on("call:busy", ({ to }) => {
|
|
const targetSocketId = onlineUsers.get(to)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("call:busy", { from: userId })
|
|
}
|
|
})
|
|
|
|
// message:deleted — broadcasts a deletion event to all other
|
|
// participants in the conversation. Looks up participant IDs
|
|
// from the database so only relevant clients are notified.
|
|
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT cp.user_id FROM conversation_participants cp
|
|
WHERE cp.conversation_id = $1 AND cp.user_id != $2`,
|
|
[conversationId, senderId || userId],
|
|
)
|
|
for (const row of result.rows) {
|
|
const targetSocketId = onlineUsers.get(row.user_id)
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit("message:deleted", { conversationId, messageId })
|
|
}
|
|
}
|
|
console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`)
|
|
} catch {
|
|
console.warn(`[message:deleted] failed to broadcast msg=${messageId}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Room-based signaling (authenticated + anonymous) ──────────
|
|
// These events support multi-participant rooms used for features
|
|
// like screen sharing or group calls. Anonymous users are assigned
|
|
// an "anon-" prefix ID.
|
|
|
|
// room:join — adds the socket to a named room. When the first
|
|
// participant joins they receive "room:waiting". When the second
|
|
// joins the first is told to initiate an offer. Additional
|
|
// participants are notified normally.
|
|
socket.on("room:join", ({ roomId, displayName }) => {
|
|
const participantId = userId || `anon-${socket.id}`
|
|
const participantLabel = displayName || participantId
|
|
|
|
if (!rooms.has(roomId)) {
|
|
rooms.set(roomId, [])
|
|
}
|
|
const participants = rooms.get(roomId)
|
|
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
|
|
socket.join(roomId)
|
|
|
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
|
|
|
if (participants.length === 1) {
|
|
socket.emit("room:waiting", { roomId, participants: list })
|
|
} else if (participants.length === 2) {
|
|
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
|
|
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
|
|
} else {
|
|
socket.emit("room:participant-joined", { roomId, participants: list })
|
|
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
|
|
}
|
|
})
|
|
|
|
// room:offer — relays an SDP offer to all other room participants.
|
|
socket.on("room:offer", ({ roomId, sdp }) => {
|
|
socket.to(roomId).emit("room:offer", { sdp })
|
|
})
|
|
|
|
// room:answer — relays an SDP answer to all other room participants.
|
|
socket.on("room:answer", ({ roomId, sdp }) => {
|
|
socket.to(roomId).emit("room:answer", { sdp })
|
|
})
|
|
|
|
// room:ice-candidate — relays ICE candidates to all other room participants.
|
|
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
|
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
|
})
|
|
|
|
// room:leave — removes the socket from a room. Cleans up the
|
|
// in-memory room map and notifies remaining participants. Rooms
|
|
// are fully deleted when empty.
|
|
socket.on("room:leave", ({ roomId }) => {
|
|
socket.leave(roomId)
|
|
const participants = rooms.get(roomId)
|
|
if (participants) {
|
|
const idx = participants.findIndex(p => p.socketId === socket.id)
|
|
if (idx !== -1) participants.splice(idx, 1)
|
|
if (participants.length === 0) {
|
|
rooms.delete(roomId)
|
|
} else {
|
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
|
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
|
}
|
|
}
|
|
})
|
|
|
|
// disconnect — fires when the socket loses connection. Cleans up
|
|
// the user from all rooms they were in, removes them from the
|
|
// online users map, and broadcasts the offline event.
|
|
socket.on("disconnect", () => {
|
|
for (const [roomId, participants] of rooms) {
|
|
const idx = participants.findIndex(p => p.socketId === socket.id)
|
|
if (idx !== -1) {
|
|
participants.splice(idx, 1)
|
|
if (participants.length === 0) {
|
|
rooms.delete(roomId)
|
|
} else {
|
|
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
|
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
|
}
|
|
}
|
|
}
|
|
if (userId) {
|
|
onlineUsers.delete(userId)
|
|
socket.broadcast.emit("user:offline", { userId })
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── Start ─────────────────────────────────────────────────────────
|
|
httpServer.listen(PORT, () => {
|
|
console.log(`[signaling] server running on port ${PORT}`)
|
|
})
|