Merge branch 'main' of https://github.com/slopus/happy-server
This commit is contained in:
commit
feb8984d86
@ -8,12 +8,13 @@
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"start": "tsx ./sources/main.ts",
|
||||
"dev": "tsx --env-file=.env.example ./sources/main.ts",
|
||||
"dev": "tsx --env-file=.env --env-file=.env.example ./sources/main.ts",
|
||||
"test": "vitest run",
|
||||
"migrate": "dotenv -e .env.example -- prisma migrate dev",
|
||||
"generate": "prisma generate",
|
||||
"postinstall": "prisma generate",
|
||||
"db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres"
|
||||
"db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres",
|
||||
"redis": "docker run -d -p 6379:6379 redis"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chalk": "^2.2.0",
|
||||
@ -57,4 +58,4 @@
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.24.3"
|
||||
}
|
||||
}
|
||||
}
|
@ -26,7 +26,14 @@ interface UserScopedConnection {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
type ClientConnection = SessionScopedConnection | UserScopedConnection;
|
||||
interface MachineScopedConnection {
|
||||
connectionType: 'machine-scoped';
|
||||
socket: Socket;
|
||||
userId: string;
|
||||
machineId: string;
|
||||
}
|
||||
|
||||
type ClientConnection = SessionScopedConnection | UserScopedConnection | MachineScopedConnection;
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
@ -132,6 +139,12 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
connection.socket.emit(event, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// Send to all machine-scoped connections - they get all user updates
|
||||
if (connection.connectionType === 'machine-scoped') {
|
||||
log({ module: 'websocket' }, `Sending ${event} to machine-scoped connection ${connection.socket.id}`);
|
||||
connection.socket.emit(event, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -314,6 +327,65 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
|
||||
// OpenAI Realtime ephemeral token generation
|
||||
typed.post('/v1/openai/realtime-token', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
token: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
// Check if OpenAI API key is configured on server
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
if (!OPENAI_API_KEY) {
|
||||
return reply.code(500).send({
|
||||
error: 'OpenAI API key not configured on server'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate ephemeral token from OpenAI
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-realtime-preview-2024-12-17',
|
||||
voice: 'verse',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
client_secret: {
|
||||
value: string;
|
||||
expires_at: number;
|
||||
};
|
||||
id: string;
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
token: data.client_secret.value
|
||||
});
|
||||
} catch (error) {
|
||||
log({ module: 'openai', level: 'error' }, 'Failed to generate ephemeral token', error);
|
||||
return reply.code(500).send({
|
||||
error: 'Failed to generate ephemeral token'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sessions API
|
||||
typed.get('/v1/sessions', {
|
||||
preHandler: app.authenticate,
|
||||
@ -928,8 +1000,9 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
io.on("connection", async (socket) => {
|
||||
log({ module: 'websocket' }, `New connection attempt from socket: ${socket.id}`);
|
||||
const token = socket.handshake.auth.token as string;
|
||||
const clientType = socket.handshake.auth.clientType as 'session-scoped' | 'user-scoped' | undefined;
|
||||
const clientType = socket.handshake.auth.clientType as 'session-scoped' | 'user-scoped' | 'machine-scoped' | undefined;
|
||||
const sessionId = socket.handshake.auth.sessionId as string | undefined;
|
||||
const machineId = socket.handshake.auth.machineId as string | undefined;
|
||||
|
||||
if (!token) {
|
||||
log({ module: 'websocket' }, `No token provided`);
|
||||
@ -945,6 +1018,14 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate machine-scoped clients have machineId
|
||||
if (clientType === 'machine-scoped' && !machineId) {
|
||||
log({ module: 'websocket' }, `Machine-scoped client missing machineId`);
|
||||
socket.emit('error', { message: 'Machine ID required for machine-scoped clients' });
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await tokenVerifier.verify(token);
|
||||
if (!verified) {
|
||||
@ -955,10 +1036,10 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
}
|
||||
|
||||
const userId = verified.user as string;
|
||||
log({ module: 'websocket' }, `Token verified: ${userId}, clientType: ${clientType || 'user-scoped'}, sessionId: ${sessionId || 'none'}, socketId: ${socket.id}`);
|
||||
log({ module: 'websocket' }, `Token verified: ${userId}, clientType: ${clientType || 'user-scoped'}, sessionId: ${sessionId || 'none'}, machineId: ${machineId || 'none'}, socketId: ${socket.id}`);
|
||||
|
||||
// Store connection based on type
|
||||
const metadata = { clientType: clientType || 'user-scoped', sessionId };
|
||||
const metadata = { clientType: clientType || 'user-scoped', sessionId, machineId };
|
||||
let connection: ClientConnection;
|
||||
if (metadata.clientType === 'session-scoped' && sessionId) {
|
||||
connection = {
|
||||
@ -967,6 +1048,13 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
userId,
|
||||
sessionId
|
||||
};
|
||||
} else if (metadata.clientType === 'machine-scoped' && machineId) {
|
||||
connection = {
|
||||
connectionType: 'machine-scoped',
|
||||
socket,
|
||||
userId,
|
||||
machineId
|
||||
};
|
||||
} else {
|
||||
connection = {
|
||||
connectionType: 'user-scoped',
|
||||
@ -978,6 +1066,22 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
userIdToClientConnections.set(userId, new Set());
|
||||
}
|
||||
userIdToClientConnections.get(userId)!.add(connection);
|
||||
|
||||
// Broadcast daemon online status
|
||||
if (connection.connectionType === 'machine-scoped') {
|
||||
// Broadcast daemon online
|
||||
emitUpdateToInterestedClients({
|
||||
event: 'ephemeral',
|
||||
userId,
|
||||
sessionId: '', // No specific session
|
||||
payload: {
|
||||
type: 'daemon-status',
|
||||
machineId,
|
||||
status: 'online',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lock
|
||||
const receiveMessageLock = new AsyncLock();
|
||||
@ -1016,6 +1120,21 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
||||
}
|
||||
|
||||
log({ module: 'websocket' }, `User disconnected: ${userId}`);
|
||||
|
||||
// Broadcast daemon offline status
|
||||
if (connection.connectionType === 'machine-scoped') {
|
||||
emitUpdateToInterestedClients({
|
||||
event: 'ephemeral',
|
||||
userId,
|
||||
sessionId: '', // No specific session
|
||||
payload: {
|
||||
type: 'daemon-status',
|
||||
machineId: connection.machineId,
|
||||
status: 'offline',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('session-alive', async (data: any) => {
|
||||
|
Loading…
Reference in New Issue
Block a user