feat: remove chat section from dashboard
- Remove Chat from sidebar navigation - Remove /chat route from App.tsx - Delete ChatPage component, gateway.ts client lib - Delete backend chat routes and gateway-relay WebSocket code - No other features depended on removed code
This commit is contained in:
@@ -3,9 +3,10 @@ import { cors } from "@elysiajs/cors";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { chatRoutes } from "./routes/chat";
|
||||
import { commentRoutes } from "./routes/comments";
|
||||
import { activityRoutes } from "./routes/activity";
|
||||
import { summaryRoutes } from "./routes/summaries";
|
||||
import { securityRoutes } from "./routes/security";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -121,7 +122,8 @@ const app = new Elysia()
|
||||
.use(activityRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
.use(chatRoutes)
|
||||
.use(securityRoutes)
|
||||
.use(summaryRoutes)
|
||||
|
||||
// Current user info (role, etc.)
|
||||
.get("/api/me", async ({ request }) => {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Gateway WebSocket Relay
|
||||
*
|
||||
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
|
||||
* Dashboard clients connect through the backend (authenticated via BetterAuth),
|
||||
* and messages are relayed bidirectionally.
|
||||
*
|
||||
* Architecture:
|
||||
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
|
||||
* (BetterAuth) (relay) (token auth)
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
|
||||
|
||||
type GatewayState = "disconnected" | "connecting" | "connected";
|
||||
type MessageHandler = (msg: any) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextReqId() {
|
||||
return `relay-${++reqCounter}`;
|
||||
}
|
||||
|
||||
class GatewayConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: GatewayState = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
private eventListeners = new Set<MessageHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
private connectSent = false;
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.state === "connecting") return;
|
||||
this.state = "connecting";
|
||||
this.connectSent = false;
|
||||
|
||||
if (!GATEWAY_TOKEN) {
|
||||
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
|
||||
this.state = "disconnected";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(GATEWAY_URL);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to create WebSocket:", e);
|
||||
this.state = "disconnected";
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
console.log("[gateway-relay] WebSocket open, sending handshake...");
|
||||
this.sendConnect();
|
||||
});
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
|
||||
// Handle connect.challenge — gateway may send this before we connect
|
||||
if (msg.type === "event" && msg.event === "connect.challenge") {
|
||||
console.log("[gateway-relay] Received connect challenge");
|
||||
// Token auth doesn't need signing; send connect if not yet sent
|
||||
if (!this.connectSent) {
|
||||
this.sendConnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to parse message:", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("[gateway-relay] Disconnected");
|
||||
this.state = "disconnected";
|
||||
this.ws = null;
|
||||
this.connectSent = false;
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Connection closed"));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("error", () => {
|
||||
console.error("[gateway-relay] WebSocket error");
|
||||
});
|
||||
}
|
||||
|
||||
private sendConnect() {
|
||||
if (this.connectSent) return;
|
||||
this.connectSent = true;
|
||||
|
||||
const connectId = nextReqId();
|
||||
this.sendRaw({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "dashboard-relay",
|
||||
displayName: "Hammer Dashboard",
|
||||
version: "1.0.0",
|
||||
platform: "server",
|
||||
mode: "webchat",
|
||||
instanceId: `relay-${process.pid}-${Date.now()}`,
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: {
|
||||
token: GATEWAY_TOKEN,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for handshake response
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: (payload) => {
|
||||
console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol);
|
||||
this.state = "connected";
|
||||
|
||||
// Start tick keepalive (gateway expects periodic ticks)
|
||||
const tickInterval = payload?.policy?.tickIntervalMs || 15000;
|
||||
this.tickTimer = setInterval(() => {
|
||||
this.sendRaw({ type: "tick" });
|
||||
}, tickInterval);
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("[gateway-relay] Handshake failed:", err);
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
},
|
||||
timer: setTimeout(() => {
|
||||
if (this.pendingRequests.has(connectId)) {
|
||||
this.pendingRequests.delete(connectId);
|
||||
console.error("[gateway-relay] Handshake timeout");
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
}
|
||||
}, 15000),
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.shouldReconnect && this.state === "disconnected") {
|
||||
this.connect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sendRaw(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok !== false) {
|
||||
pending.resolve(msg.payload ?? msg.result ?? {});
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || msg.error || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
// Forward events to all listeners
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Event listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore tick responses and other frame types
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Gateway not connected");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextReqId();
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.sendRaw({ type: "req", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(handler: MessageHandler): () => void {
|
||||
this.eventListeners.add(handler);
|
||||
return () => this.eventListeners.delete(handler);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.tickTimer) clearInterval(this.tickTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.state = "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton gateway connection
|
||||
export const gateway = new GatewayConnection();
|
||||
|
||||
/**
|
||||
* Send a chat message to the gateway
|
||||
*/
|
||||
export async function chatSend(sessionKey: string, message: string): Promise<any> {
|
||||
return gateway.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history from the gateway
|
||||
*/
|
||||
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
|
||||
return gateway.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress chat response
|
||||
*/
|
||||
export async function chatAbort(sessionKey: string): Promise<any> {
|
||||
return gateway.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from the gateway
|
||||
*/
|
||||
export async function sessionsList(limit = 50): Promise<any> {
|
||||
return gateway.request("sessions.list", { limit });
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Chat routes - WebSocket relay + REST fallback for dashboard chat
|
||||
*
|
||||
* WebSocket: /api/chat/ws - Real-time bidirectional relay to gateway
|
||||
* REST: /api/chat/send, /api/chat/history, /api/chat/sessions - Fallback endpoints
|
||||
*/
|
||||
import { Elysia } from "elysia";
|
||||
import { auth } from "../lib/auth";
|
||||
import { gateway, chatSend, chatHistory, chatAbort, sessionsList } from "../lib/gateway-relay";
|
||||
|
||||
// Track active WebSocket client connections
|
||||
const activeClients = new Map<string, {
|
||||
ws: any;
|
||||
userId: string;
|
||||
sessionKeys: Set<string>;
|
||||
}>();
|
||||
|
||||
export const chatRoutes = new Elysia()
|
||||
// WebSocket endpoint for real-time chat relay
|
||||
.ws("/api/chat/ws", {
|
||||
open(ws) {
|
||||
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
(ws.data as any).__clientId = clientId;
|
||||
(ws.data as any).__authenticated = false;
|
||||
console.log(`[chat-ws] Client connected: ${clientId}`);
|
||||
},
|
||||
|
||||
async message(ws, rawMsg) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
|
||||
let msg: any;
|
||||
try {
|
||||
msg = typeof rawMsg === "string" ? JSON.parse(rawMsg) : rawMsg;
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// First message must be auth
|
||||
if (!(ws.data as any).__authenticated) {
|
||||
if (msg.type !== "auth") {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Must authenticate first" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session cookie or token
|
||||
const session = await validateAuth(msg);
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Authentication failed" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
(ws.data as any).__authenticated = true;
|
||||
(ws.data as any).__userId = session.user.id;
|
||||
(ws.data as any).__userName = session.user.name || session.user.email;
|
||||
|
||||
// Register client
|
||||
activeClients.set(clientId, {
|
||||
ws,
|
||||
userId: session.user.id,
|
||||
sessionKeys: new Set(),
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: "auth_ok",
|
||||
user: { id: session.user.id, name: session.user.name },
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
}));
|
||||
|
||||
console.log(`[chat-ws] Client authenticated: ${clientId} (${session.user.name || session.user.email})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authenticated messages
|
||||
try {
|
||||
await handleClientMessage(clientId, ws, msg);
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
id: msg.id,
|
||||
error: e.message || "Internal error",
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
activeClients.delete(clientId);
|
||||
console.log(`[chat-ws] Client disconnected: ${clientId}`);
|
||||
},
|
||||
})
|
||||
|
||||
// REST: Send a chat message
|
||||
.post("/api/chat/send", async ({ request, body }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
const { sessionKey, message } = body as { sessionKey: string; message: string };
|
||||
if (!sessionKey || !message) {
|
||||
return new Response(JSON.stringify({ error: "sessionKey and message required" }), { status: 400 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatSend(sessionKey, message);
|
||||
return { ok: true, result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Get chat history
|
||||
.get("/api/chat/history/:sessionKey", async ({ request, params }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatHistory(params.sessionKey);
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: List sessions
|
||||
.get("/api/chat/sessions", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sessionsList();
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Gateway connection status
|
||||
.get("/api/chat/status", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
activeClients: activeClients.size,
|
||||
};
|
||||
});
|
||||
|
||||
// Validate auth from WebSocket auth message
|
||||
async function validateAuth(msg: any): Promise<any> {
|
||||
// Support cookie-based auth (pass cookie string)
|
||||
if (msg.cookie) {
|
||||
try {
|
||||
// Create a fake request with the cookie header for BetterAuth
|
||||
const headers = new Headers();
|
||||
headers.set("cookie", msg.cookie);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Support bearer token auth
|
||||
if (msg.token) {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${msg.token}`);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle messages from authenticated WebSocket clients
|
||||
async function handleClientMessage(clientId: string, ws: any, msg: any) {
|
||||
const client = activeClients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "chat.send": {
|
||||
const { sessionKey, message } = msg;
|
||||
if (!sessionKey || !message) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey and message required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatSend(sessionKey, message);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.history": {
|
||||
const { sessionKey, limit } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatHistory(sessionKey, limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.abort": {
|
||||
const { sessionKey } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
const result = await chatAbort(sessionKey);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessions.list": {
|
||||
const result = await sessionsList(msg.limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: `Unknown message type: ${msg.type}` }));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward gateway events to relevant WebSocket clients
|
||||
gateway.onEvent((msg: any) => {
|
||||
if (msg.type !== "event") return;
|
||||
|
||||
const payload = msg.payload || {};
|
||||
const sessionKey = payload.sessionKey;
|
||||
|
||||
for (const [, client] of activeClients) {
|
||||
// Forward to clients subscribed to this session key, or broadcast if no key
|
||||
if (!sessionKey || client.sessionKeys.has(sessionKey)) {
|
||||
try {
|
||||
client.ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// Client disconnected, will be cleaned up
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user