feat(backend,frontend): migrate data APIs from REST to native tRPC over WebSocket
Replace REST module routers with a single typed tRPC appRouter served over
/trpc (HTTP + WebSocket), and rewire the frontend to call it via
@trpc/client wsLink (browser) and httpLink (RSC data layer). Existing
/api/health + /api/metrics stay as plain Express for infra scraping.
Notable fixes surfaced by the live smoke test:
- Express 5 / path-to-regexp v8 rejects the /trpc/* wildcard route; use a
prefix middleware that computes opts.path from the URL instead.
- nodeHTTPRequestHandler treats opts.path as the literal procedure path, so
it is derived per-request from req.url.
- Two ws servers on one http.Server (the /ws voice socket + /trpc) collided
and returned 400 on upgrade; both now use noServer + a manually routed
server.on('upgrade') keyed by path.
Verified: BE tsc+biome+40 vitest green; FE tsc+biome green; live
HTTP and WebSocket calls returned real prod data.
Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d8552a9fb8
commit
2fa1827f17
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
|
"@trpc/server": "^11.18.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -31,10 +32,10 @@
|
|||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^25.9.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2572
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http";
|
||||||
import express, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
@@ -6,20 +7,17 @@ import express, {
|
|||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
|
||||||
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
|
||||||
import { createConfigRouter } from "../modules/config/index.js";
|
|
||||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
|
||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createMediaRouter } from "../modules/media/index.js";
|
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
|
||||||
import { createModerationRouter } from "../modules/moderation/index.js";
|
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
|
||||||
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
|
||||||
import { createVoiceRouter } from "../modules/voice/index.js";
|
|
||||||
import { errorHandler } from "../shared/middlewares/index.js";
|
import { errorHandler } from "../shared/middlewares/index.js";
|
||||||
|
import { appRouter } from "../trpc/routers";
|
||||||
|
|
||||||
// Auth removed — dashboard is public
|
// Auth removed — dashboard is public.
|
||||||
|
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
|
||||||
|
// analysis, chatbot, config, ui-state) now flow over tRPC, served on TWO
|
||||||
|
// transports sharing the /trpc path:
|
||||||
|
// - WebSocket (browser live RPCs) — see trpc/ws.ts
|
||||||
|
// - HTTP POST (server-side / RSC fetch) — handled below
|
||||||
|
// Only infra endpoints (health, prometheus metrics) remain plain HTTP.
|
||||||
|
|
||||||
const logger = createChildLogger("http.app");
|
const logger = createChildLogger("http.app");
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ export function createHttpApp(): Express {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing (still needed for any JSON POST; tRPC is WS-based)
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
@@ -59,24 +57,40 @@ export function createHttpApp(): Express {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// All routes are public
|
// Infra-only HTTP endpoints
|
||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
app.use("/api", createConfigRouter());
|
|
||||||
app.use("/api", createDashboardRouter());
|
// tRPC over HTTP (server-side / RSC fetch). The context has no WebSocket
|
||||||
app.use("/api", createMessagesRouter());
|
// here (that's the WS transport's job); procedures don't read ctx.conn, so
|
||||||
app.use("/api", createAnalysisRouter());
|
// a null conn is safe.
|
||||||
app.use("/api", createChatbotRouter());
|
// NOTE: Express 5 (path-to-regexp v8) rejects the `"/trpc/*"` wildcard route,
|
||||||
app.use("/api", createRecordingsRouter());
|
// and `nodeHTTPRequestHandler` uses `opts.path` as the literal procedure
|
||||||
app.use("/api", createUiStateRouter());
|
// path (it does NOT derive it from `req.url`). So we mount a plain
|
||||||
app.use("/api", createMediaRouter());
|
// middleware and compute the procedure path from the URL ourselves.
|
||||||
app.use("/api", createVoiceRouter());
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
app.use("/api", createModerationRouter());
|
if (!req.path.startsWith("/trpc")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const procPath = req.url.replace(/^\/trpc\/?/, "").split("?")[0] || "/";
|
||||||
|
nodeHTTPRequestHandler({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => ({ conn: null }),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
path: procPath,
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
logger.error({ err }, "tRPC HTTP handler failed");
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: "NOT_FOUND",
|
error: "NOT_FOUND",
|
||||||
message: "Endpoint not found",
|
message:
|
||||||
|
"Endpoint not found — data APIs are served over /trpc (WebSocket/HTTP)",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createServer, type Server } from "node:http";
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { initializeDatabase } from "../shared/database/index.js";
|
import { initializeDatabase } from "../shared/database/index.js";
|
||||||
|
import { createTRPCWebSocketServer } from "../trpc/ws.js";
|
||||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||||
import { createWebSocketServer } from "../ws/server.js";
|
import { createWebSocketServer } from "../ws/server.js";
|
||||||
import { createHttpApp } from "./app.js";
|
import { createHttpApp } from "./app.js";
|
||||||
@@ -16,8 +17,9 @@ export async function startHttpServer(): Promise<Server> {
|
|||||||
|
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
// Attach WebSocket server to the same HTTP server
|
// Attach WebSocket servers to the same HTTP server
|
||||||
createWebSocketServer(server);
|
createWebSocketServer(server); // /ws — voice PCM + gateway events
|
||||||
|
createTRPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||||
|
|
||||||
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
||||||
await startRedisBridge();
|
await startRedisBridge();
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { analysisService } from "./analysis.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("analysis.routes");
|
|
||||||
|
|
||||||
export function createAnalysisRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/analysis/search
|
|
||||||
router.get(
|
|
||||||
"/analysis/search",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const q = (req.query.q as string) || "";
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
|
|
||||||
logger.debug({ q, channelId, limit }, "Analysis search requested");
|
|
||||||
const result = await analysisService.search({ q, channelId, limit });
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createAnalysisRouter } from "./analysis.routes.js";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { chatbotService } from "./chatbot.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.controller");
|
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
|
|
||||||
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
|
|
||||||
* auth middleware userId takes precedence when present.
|
|
||||||
*/
|
|
||||||
function resolveUserId(req: Request): string {
|
|
||||||
const authId = (req as AuthenticatedRequest).userId;
|
|
||||||
if (authId) return authId;
|
|
||||||
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
|
|
||||||
return header || "anonymous";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleChatbotChat = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { message, context } = req.body as {
|
|
||||||
message: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!message || typeof message !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "INVALID_INPUT",
|
|
||||||
message: "Message is required and must be a string",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID from X-User-Id header (no-login device uuid) or auth
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{ userId, messageLength: message.length, context },
|
|
||||||
"Received chatbot chat message",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process message & generate response
|
|
||||||
const response = await chatbotService.processMessage(
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save conversation to database
|
|
||||||
await chatbotService.saveConversation({
|
|
||||||
userId,
|
|
||||||
userMessage: message,
|
|
||||||
botResponse: response,
|
|
||||||
context,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info({ userId }, "Chatbot chat processed successfully");
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
response,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
|
||||||
|
|
||||||
const history = await chatbotService.getChatHistory(userId, limit);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
history,
|
|
||||||
total: history.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clearChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
await chatbotService.clearChatHistory(userId);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
message: "Chat history cleared successfully",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import express, { type Router } from "express";
|
|
||||||
import { validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
clearChatbotHistory,
|
|
||||||
getChatbotHistory,
|
|
||||||
handleChatbotChat,
|
|
||||||
} from "./chatbot.controller.js";
|
|
||||||
import { chatRequestSchema } from "./chatbot.schema.js";
|
|
||||||
|
|
||||||
export function createChatbotRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
|
|
||||||
router.get("/chat/history", getChatbotHistory);
|
|
||||||
router.delete("/chat/history", clearChatbotHistory);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createChatbotRouter } from "./chatbot.routes.js";
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { config } from "../../shared/config/index.js";
|
|
||||||
|
|
||||||
export function createConfigRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/config
|
|
||||||
router.get("/config", (_req, res) => {
|
|
||||||
res.json({
|
|
||||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
|
||||||
webserverPort: config.WEBSERVER_PORT,
|
|
||||||
nodeEnv: config.NODE_ENV,
|
|
||||||
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
|
||||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
|
||||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
|
||||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
|
||||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
|
||||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
|
||||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
|
||||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
|
||||||
logLevel: config.LOG_LEVEL,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createConfigRouter } from "./config.routes.js";
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { dashboardService } from "./dashboard.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("dashboard.routes");
|
|
||||||
|
|
||||||
export function createDashboardRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/dashboard/stats — aggregated server statistics
|
|
||||||
router.get(
|
|
||||||
"/dashboard/stats",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching dashboard stats");
|
|
||||||
const stats = await dashboardService.getStats();
|
|
||||||
res.json(stats);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/activity?days=14 — message volume over time
|
|
||||||
router.get(
|
|
||||||
"/dashboard/activity",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
|
||||||
const activity = await dashboardService.getActivity(days);
|
|
||||||
res.json(activity);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users — paginated user list with profiles
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const cursor =
|
|
||||||
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listUsers({
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users/:userId — single user detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users/:userId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const userId = String(req.params.userId);
|
|
||||||
const detail = await dashboardService.getUserDetail(userId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels — paginated channel list with culture summaries
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
const guildId =
|
|
||||||
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listChannels({
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
guildId,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels/:channelId — single channel detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels/:channelId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const channelId = String(req.params.channelId);
|
|
||||||
const detail = await dashboardService.getChannelDetail(channelId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactions — top reacted messages
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactions",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactions = await dashboardService.getTopReactions(limit);
|
|
||||||
res.json(reactions);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactors — top users by reactions given
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactors",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactors = await dashboardService.getTopReactors(limit);
|
|
||||||
res.json(reactors);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createDashboardRouter } from "./dashboard.routes.js";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMediaRouter } from "./media.routes.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
|
|
||||||
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("media.routes");
|
|
||||||
|
|
||||||
export function createMediaRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/media/status
|
|
||||||
router.get(
|
|
||||||
"/media/status",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media status requested");
|
|
||||||
const status = await getStatus();
|
|
||||||
res.json(status);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/queue
|
|
||||||
router.post(
|
|
||||||
"/media/queue",
|
|
||||||
validateBody(mediaQueueSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { source, mode } = req.body as {
|
|
||||||
source: string;
|
|
||||||
mode: "music" | "screen";
|
|
||||||
};
|
|
||||||
logger.debug({ source, mode }, "Media queue requested");
|
|
||||||
const state = await queue(source, mode);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/skip
|
|
||||||
router.post(
|
|
||||||
"/media/skip",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media skip requested");
|
|
||||||
const state = await skip();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/stop
|
|
||||||
router.post(
|
|
||||||
"/media/stop",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media stop requested");
|
|
||||||
const state = await stop();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/loop
|
|
||||||
router.post(
|
|
||||||
"/media/loop",
|
|
||||||
validateBody(mediaLoopSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { loop } = req.body as { loop: boolean };
|
|
||||||
logger.debug({ loop }, "Media loop requested");
|
|
||||||
const state = await setLoop(loop);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMessagesRouter } from "./messages.routes.js";
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { messageQuerySchema } from "./messages.schema.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.controller");
|
|
||||||
|
|
||||||
export const handleListMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ query }, "Handling list messages request");
|
|
||||||
const result = await messagesService.listMessages(query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessagesByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
|
||||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessageById = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.id) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: id" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = req.params.id as string;
|
|
||||||
logger.debug({ id }, "Handling get message by ID");
|
|
||||||
const result = await messagesService.getMessageById(id);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetImageMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.query.guildId as string | undefined;
|
|
||||||
if (!guildId) {
|
|
||||||
res.status(400).json({ error: "Missing query parameter: guildId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
logger.debug({ guildId, limit }, "Handling get image messages");
|
|
||||||
const result = await messagesService.getImageMessages(guildId, limit);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetAttachmentsByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
|
||||||
const result = await messagesService.getAttachmentsByChannel(
|
|
||||||
channelId,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
handleGetAttachmentsByChannel,
|
|
||||||
handleGetImageMessages,
|
|
||||||
handleGetMessageById,
|
|
||||||
handleGetMessagesByChannel,
|
|
||||||
handleListMessages,
|
|
||||||
} from "./messages.controller.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.routes");
|
|
||||||
|
|
||||||
export function createMessagesRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/messages/images - Get messages with image attachments
|
|
||||||
// MUST be registered BEFORE /messages/:channelId so "images" is not
|
|
||||||
// captured as a channelId param.
|
|
||||||
router.get("/messages/images", handleGetImageMessages);
|
|
||||||
|
|
||||||
// GET /api/messages - List messages
|
|
||||||
router.get("/messages", handleListMessages);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId - Get messages by channel
|
|
||||||
router.get("/messages/:channelId", handleGetMessagesByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId/attachments - Get attachments by channel
|
|
||||||
router.get("/messages/:channelId/attachments", handleGetAttachmentsByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/detail/:id - Get single message by ID
|
|
||||||
// (uses /detail/ prefix to avoid collision with :channelId route above)
|
|
||||||
router.get("/messages/detail/:id", handleGetMessageById);
|
|
||||||
|
|
||||||
// GET /api/review - Get flagged/warned messages for review
|
|
||||||
router.get(
|
|
||||||
"/review",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
|
|
||||||
const rows = await messagesService.getReviewMessages(channelId, limit);
|
|
||||||
logger.debug({ limit, channelId }, "Review query executed");
|
|
||||||
res.json({ results: rows, limit, cursor: null });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createModerationRouter } from "./moderation.routes.js";
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { moderationService } from "./moderation.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("moderation.routes");
|
|
||||||
|
|
||||||
export function createModerationRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/moderation/stats — moderation action summary
|
|
||||||
router.get(
|
|
||||||
"/moderation/stats",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
const stats = await moderationService.getStats();
|
|
||||||
res.json(stats);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/moderation/actions — paginated moderation action log
|
|
||||||
router.get(
|
|
||||||
"/moderation/actions",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
const status = req.query.status as string | undefined;
|
|
||||||
const actionType = req.query.actionType as string | undefined;
|
|
||||||
const cursor = req.query.cursor as string | undefined;
|
|
||||||
|
|
||||||
const result = await moderationService.listActions({
|
|
||||||
limit,
|
|
||||||
status,
|
|
||||||
actionType,
|
|
||||||
cursor: cursor ? Number(cursor) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug({ count: result.data.length }, "Moderation actions listed");
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createRecordingsRouter } from "./recordings.routes.js";
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { recordingsService } from "./recordings.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("recordings.routes");
|
|
||||||
|
|
||||||
export function createRecordingsRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/recordings
|
|
||||||
router.get(
|
|
||||||
"/recordings",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
const channelId = req.query.channelId as string | undefined;
|
|
||||||
const userId = req.query.userId as string | undefined;
|
|
||||||
const cursor = req.query.cursor as string | undefined;
|
|
||||||
logger.debug({ limit, channelId, userId, cursor }, "Fetching recordings");
|
|
||||||
const result = await recordingsService.getRecent(limit, {
|
|
||||||
channelId,
|
|
||||||
userId,
|
|
||||||
cursor,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/recordings/:id
|
|
||||||
router.delete(
|
|
||||||
"/recordings/:id",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const id = req.params.id as string;
|
|
||||||
await recordingsService.deleteById(id);
|
|
||||||
res.json({ ok: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createUiStateRouter } from "./ui-state.routes.js";
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { uiStateService } from "./ui-state.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("ui-state.routes");
|
|
||||||
|
|
||||||
export function createUiStateRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/ui-state
|
|
||||||
router.get(
|
|
||||||
"/ui-state",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching UI state");
|
|
||||||
const state = await uiStateService.getState();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/ui-state
|
|
||||||
router.post(
|
|
||||||
"/ui-state",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const updates = req.body as Record<string, unknown>;
|
|
||||||
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
|
||||||
const result = await uiStateService.updateState(updates);
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createVoiceRouter } from "./voice.routes.js";
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
|
||||||
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
|
|
||||||
import {
|
|
||||||
connectVoice,
|
|
||||||
disconnectVoice,
|
|
||||||
getVoiceStatus,
|
|
||||||
} from "./voice.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice.controller");
|
|
||||||
|
|
||||||
export const handleGetVoiceStatus = asyncHandler(
|
|
||||||
async (_req: Request, res: Response) => {
|
|
||||||
const status = await getVoiceStatus();
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleConnectVoice = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { guildId, channelId } = req.body as ConnectVoiceInput;
|
|
||||||
logger.debug({ guildId, channelId }, "Connecting to voice channel");
|
|
||||||
const status = await connectVoice(guildId, channelId);
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleDisconnectVoice = asyncHandler(
|
|
||||||
async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Disconnecting from voice");
|
|
||||||
const status = await disconnectVoice();
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleVoiceCommand = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { command } = req.body as VoiceCommandInput;
|
|
||||||
logger.debug({ command }, "Publishing voice command");
|
|
||||||
await publishCommandNoReply(command);
|
|
||||||
res.json({ success: true, command });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
handleConnectVoice,
|
|
||||||
handleDisconnectVoice,
|
|
||||||
handleGetVoiceStatus,
|
|
||||||
handleVoiceCommand,
|
|
||||||
} from "./voice.controller.js";
|
|
||||||
import { connectVoiceSchema, voiceCommandSchema } from "./voice.schema.js";
|
|
||||||
import {
|
|
||||||
getGuilds,
|
|
||||||
getTextChannels,
|
|
||||||
getVoiceChannels,
|
|
||||||
} from "./voice.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice.routes");
|
|
||||||
|
|
||||||
export function createVoiceRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// ── Guilds ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// GET /api/guilds
|
|
||||||
router.get(
|
|
||||||
"/guilds",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching guilds");
|
|
||||||
const guilds = await getGuilds();
|
|
||||||
res.json(guilds);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/channels
|
|
||||||
router.get(
|
|
||||||
"/guilds/:guildId/channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.params.guildId as string;
|
|
||||||
logger.debug({ guildId }, "Fetching text channels");
|
|
||||||
const channels = await getTextChannels(guildId);
|
|
||||||
res.json(channels);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/voice-channels
|
|
||||||
router.get(
|
|
||||||
"/guilds/:guildId/voice-channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.params.guildId as string;
|
|
||||||
logger.debug({ guildId }, "Fetching voice channels");
|
|
||||||
const channels = await getVoiceChannels(guildId);
|
|
||||||
res.json(channels);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Voice connection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// GET /api/voice/status
|
|
||||||
router.get("/voice/status", handleGetVoiceStatus);
|
|
||||||
|
|
||||||
// POST /api/voice/connect
|
|
||||||
router.post(
|
|
||||||
"/voice/connect",
|
|
||||||
validateBody(connectVoiceSchema),
|
|
||||||
handleConnectVoice,
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/voice/disconnect
|
|
||||||
router.post("/voice/disconnect", handleDisconnectVoice);
|
|
||||||
|
|
||||||
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
|
|
||||||
router.post(
|
|
||||||
"/voice/command",
|
|
||||||
validateBody(voiceCommandSchema),
|
|
||||||
handleVoiceCommand,
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { analysisService } from "../modules/analysis/analysis.service";
|
||||||
|
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||||
|
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||||
|
// ── Service imports ──────────────────────────────────────────────
|
||||||
|
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||||
|
import {
|
||||||
|
mediaLoopSchema,
|
||||||
|
mediaQueueSchema,
|
||||||
|
} from "../modules/media/media.schema";
|
||||||
|
import {
|
||||||
|
getStatus,
|
||||||
|
queue,
|
||||||
|
setLoop,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
} from "../modules/media/media.service";
|
||||||
|
import { messageQuerySchema } from "../modules/messages/messages.schema";
|
||||||
|
import { messagesService } from "../modules/messages/messages.service";
|
||||||
|
import { moderationService } from "../modules/moderation/moderation.service";
|
||||||
|
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||||
|
import { uiStateService } from "../modules/ui-state/ui-state.service";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../modules/voice/voice.service";
|
||||||
|
import { config } from "../shared/config/index";
|
||||||
|
import { publishCommandNoReply } from "../shared/redis/index";
|
||||||
|
import { logger, publicProcedure, router } from "./trpc";
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────────
|
||||||
|
const dashboardRouter = router({
|
||||||
|
stats: publicProcedure.query(() => dashboardService.getStats()),
|
||||||
|
activity: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
|
||||||
|
)
|
||||||
|
.query(({ input }) => dashboardService.getActivity(input.days)),
|
||||||
|
users: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
dashboardService.listUsers({
|
||||||
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
|
search: input.search,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
userDetail: publicProcedure
|
||||||
|
.input(z.object({ userId: z.string() }))
|
||||||
|
.query(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||||
|
channels: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
search: z.string().optional(),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
dashboardService.listChannels({
|
||||||
|
limit: input.limit,
|
||||||
|
search: input.search,
|
||||||
|
guildId: input.guildId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
channelDetail: publicProcedure
|
||||||
|
.input(z.object({ channelId: z.string() }))
|
||||||
|
.query(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||||
|
reactions: publicProcedure
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.query(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||||
|
reactors: publicProcedure
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.query(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────
|
||||||
|
const messagesRouter = router({
|
||||||
|
list: publicProcedure
|
||||||
|
.input(messageQuerySchema)
|
||||||
|
.query(({ input }) => messagesService.listMessages(input)),
|
||||||
|
byChannel: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
messagesService.getMessagesByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
detail: publicProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(({ input }) => messagesService.getMessageById(input.id)),
|
||||||
|
images: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
guildId: z.string(),
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
messagesService.getImageMessages(input.guildId, input.limit),
|
||||||
|
),
|
||||||
|
attachmentsByChannel: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
review: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const rows = await messagesService.getReviewMessages(
|
||||||
|
input.channelId,
|
||||||
|
input.limit,
|
||||||
|
);
|
||||||
|
return { results: rows, limit: input.limit, cursor: null };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Moderation ───────────────────────────────────────────────────
|
||||||
|
const moderationRouter = router({
|
||||||
|
stats: publicProcedure.query(() => moderationService.getStats()),
|
||||||
|
actions: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
status: z.string().optional(),
|
||||||
|
actionType: z.string().optional(),
|
||||||
|
cursor: z.coerce.number().int().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
moderationService.listActions({
|
||||||
|
limit: input.limit,
|
||||||
|
status: input.status,
|
||||||
|
actionType: input.actionType,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Media ────────────────────────────────────────────────────────
|
||||||
|
const mediaRouter = router({
|
||||||
|
status: publicProcedure.query(() => getStatus()),
|
||||||
|
queue: publicProcedure.input(mediaQueueSchema).mutation(async ({ input }) => {
|
||||||
|
await queue(input.source, input.mode);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
skip: publicProcedure.mutation(async () => {
|
||||||
|
await skip();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
stop: publicProcedure.mutation(async () => {
|
||||||
|
await stop();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
loop: publicProcedure.input(mediaLoopSchema).mutation(async ({ input }) => {
|
||||||
|
await setLoop(input.loop);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Voice ─────────────────────────────────────────────────────────
|
||||||
|
const voiceRouter = router({
|
||||||
|
guilds: publicProcedure.query(() => getGuilds()),
|
||||||
|
textChannels: publicProcedure
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.query(({ input }) => getTextChannels(input.guildId)),
|
||||||
|
voiceChannels: publicProcedure
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.query(({ input }) => getVoiceChannels(input.guildId)),
|
||||||
|
status: publicProcedure.query(() => getVoiceStatus()),
|
||||||
|
connect: publicProcedure
|
||||||
|
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await connectVoice(input.guildId, input.channelId);
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
disconnect: publicProcedure.mutation(async () => {
|
||||||
|
await disconnectVoice();
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
command: publicProcedure
|
||||||
|
.input(z.object({ command: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await publishCommandNoReply(input.command);
|
||||||
|
return { success: true, command: input.command };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Recordings ───────────────────────────────────────────────────
|
||||||
|
const recordingsRouter = router({
|
||||||
|
list: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
recordingsService.getRecent(input.limit, {
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await recordingsService.deleteById(input.id);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
|
const analysisRouter = router({
|
||||||
|
search: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().default(""),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input }) =>
|
||||||
|
analysisService.search({
|
||||||
|
q: input.q,
|
||||||
|
channelId: input.channelId,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chatbot ───────────────────────────────────────────────────────
|
||||||
|
const chatbotRouter = router({
|
||||||
|
chat: publicProcedure
|
||||||
|
.input(
|
||||||
|
chatRequestSchema.extend({
|
||||||
|
// Per-device actor id; the old REST layer used an X-User-Id header.
|
||||||
|
// Anonymous sessions use a stable "anonymous" id.
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const response = await chatbotService.processMessage(
|
||||||
|
input.message,
|
||||||
|
input.context,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await chatbotService.saveConversation({
|
||||||
|
userId,
|
||||||
|
userMessage: input.message,
|
||||||
|
botResponse: response,
|
||||||
|
context: input.context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return { response, timestamp: new Date().toISOString() };
|
||||||
|
}),
|
||||||
|
history: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const history = await chatbotService.getChatHistory(userId, input.limit);
|
||||||
|
return { history, total: history.length };
|
||||||
|
}),
|
||||||
|
clearHistory: publicProcedure
|
||||||
|
.input(z.object({ userId: z.string().optional() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
await chatbotService.clearChatHistory(userId);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Config (public dashboard config snapshot) ──────────────────────
|
||||||
|
const configRouter = router({
|
||||||
|
get: publicProcedure.query(() => ({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
webserverPort: config.WEBSERVER_PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
||||||
|
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
|
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
|
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
|
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
||||||
|
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||||
|
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||||
|
voiceGuildId: config.VOICE_GUILD_ID || null,
|
||||||
|
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||||
|
logLevel: config.LOG_LEVEL,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── UI State ──────────────────────────────────────────────────────
|
||||||
|
const uiStateRouter = router({
|
||||||
|
get: publicProcedure.query(() => uiStateService.getState()),
|
||||||
|
update: publicProcedure
|
||||||
|
.input(z.record(z.string(), z.unknown()))
|
||||||
|
.mutation(({ input }) => uiStateService.updateState(input)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Root router ───────────────────────────────────────────────────
|
||||||
|
export const appRouter = router({
|
||||||
|
dashboard: dashboardRouter,
|
||||||
|
messages: messagesRouter,
|
||||||
|
moderation: moderationRouter,
|
||||||
|
media: mediaRouter,
|
||||||
|
voice: voiceRouter,
|
||||||
|
recordings: recordingsRouter,
|
||||||
|
analysis: analysisRouter,
|
||||||
|
chatbot: chatbotRouter,
|
||||||
|
config: configRouter,
|
||||||
|
uiState: uiStateRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
||||||
|
logger.info("tRPC appRouter constructed");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const logger = createChildLogger("trpc");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tRPC context. The WebSocket transport enriches each request with the raw
|
||||||
|
* socket so procedures can, if needed, inspect connection metadata. The
|
||||||
|
* dashboard is public (no auth), mirroring the previous REST layer.
|
||||||
|
*/
|
||||||
|
export interface TRPCContext {
|
||||||
|
conn: WebSocket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = initTRPC.context<TRPCContext>().create({
|
||||||
|
errorFormatter({ shape, error }) {
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
data: {
|
||||||
|
...shape.data,
|
||||||
|
// Surface a stable code + message for client-side handling.
|
||||||
|
code: error.code,
|
||||||
|
stack: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = t.router;
|
||||||
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
|
// Re-export so routers can import z from one place if desired.
|
||||||
|
export { z } from "zod";
|
||||||
|
export { logger };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
|
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { appRouter } from "./routers";
|
||||||
|
|
||||||
|
const logger = createChildLogger("trpc.ws");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the tRPC WebSocket handler to the shared HTTP server, on a path
|
||||||
|
* SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs
|
||||||
|
* (dashboard, messages, moderation, media, voice control, recordings,
|
||||||
|
* analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the
|
||||||
|
* `/ws` socket is left untouched for Discord PCM audio + gateway events.
|
||||||
|
*
|
||||||
|
* We use `noServer` + a manual `upgrade` router (instead of
|
||||||
|
* `new WebSocketServer({ server, path: "/trpc" })`) because two `ws` servers
|
||||||
|
* mounted with the `server` option on the SAME http.Server both register
|
||||||
|
* `upgrade` listeners, and `ws`'s path-guarded listener can reject (400) the
|
||||||
|
* other server's path. Routing the upgrade ourselves by URL keeps `/trpc`
|
||||||
|
* and `/ws` fully isolated.
|
||||||
|
*/
|
||||||
|
export function createTRPCWebSocketServer(server: Server): WebSocketServer {
|
||||||
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||||||
|
|
||||||
|
applyWSSHandler({
|
||||||
|
wss,
|
||||||
|
prefix: "/trpc",
|
||||||
|
router: appRouter,
|
||||||
|
createContext: (opts) => ({ conn: opts.res }),
|
||||||
|
keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 },
|
||||||
|
onError: (err) => {
|
||||||
|
logger.error({ err }, "tRPC WS error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ path: "/trpc" }, "tRPC WebSocket server attached");
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Server } from "node:http";
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||||
@@ -96,9 +97,20 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
const frontendClients = new Set<WebSocket>();
|
const frontendClients = new Set<WebSocket>();
|
||||||
const gatewayClients = new Set<WebSocket>();
|
const gatewayClients = new Set<WebSocket>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
|
|
||||||
|
// Manual upgrade routing: without this, two `ws` servers bound to the same
|
||||||
|
// http.Server via the `server` option both register `upgrade` listeners and
|
||||||
|
// the path-guarded one destructively rejects the other's path (400). We own
|
||||||
|
// the upgrade event and dispatch by URL instead.
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/ws")) return;
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Map-based dispatcher for JSON WebSocket message types
|
// Map-based dispatcher for JSON WebSocket message types
|
||||||
const jsonHandlers = new Map<string, MessageHandler>();
|
const jsonHandlers = new Map<string, MessageHandler>();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trpc/client": "^11.18.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"motion": "^12.0.0",
|
"motion": "^12.0.0",
|
||||||
|
|||||||
Generated
+25
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@trpc/client':
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -538,6 +541,19 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.3.3':
|
'@tailwindcss/postcss@4.3.3':
|
||||||
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
||||||
|
|
||||||
|
'@trpc/client@11.18.0':
|
||||||
|
resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@trpc/server': 11.18.0
|
||||||
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
|
'@trpc/server@11.18.0':
|
||||||
|
resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3':
|
'@tweenjs/tween.js@23.1.3':
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
@@ -1376,6 +1392,15 @@ snapshots:
|
|||||||
postcss: 8.5.25
|
postcss: 8.5.25
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
|
'@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@trpc/server': 11.18.0(typescript@5.9.3)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@trpc/server@11.18.0(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types";
|
import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
function userHeader(userId?: string): Record<string, string> {
|
|
||||||
return userId && userId !== "anonymous" ? { "X-User-Id": userId } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatbotApi = {
|
export const chatbotApi = {
|
||||||
send: (message: string, guildId?: string, userId?: string) =>
|
send: (message: string, guildId?: string, userId?: string) =>
|
||||||
api.post<ChatbotResponse>(
|
trpc.chatbot.chat.mutate({
|
||||||
"/api/chat",
|
|
||||||
{
|
|
||||||
message,
|
message,
|
||||||
context: guildId ? { guildId } : undefined,
|
context: guildId ? { guildId } : undefined,
|
||||||
},
|
userId,
|
||||||
userHeader(userId),
|
}) as unknown as Promise<ChatbotResponse>,
|
||||||
),
|
|
||||||
|
|
||||||
getHistory: (userId?: string) =>
|
getHistory: (userId?: string) =>
|
||||||
api.get<{ history: ChatbotHistoryRow[]; total: number }>(
|
trpc.chatbot.history.query({
|
||||||
"/api/chat/history",
|
limit: 50,
|
||||||
userHeader(userId),
|
userId,
|
||||||
),
|
}) as unknown as Promise<{ history: ChatbotHistoryRow[]; total: number }>,
|
||||||
|
|
||||||
clearHistory: (userId?: string) =>
|
clearHistory: (userId?: string) =>
|
||||||
api.delete<{ ok: boolean }>("/api/chat/history", userHeader(userId)),
|
trpc.chatbot.clearHistory.mutate({
|
||||||
|
userId,
|
||||||
|
}) as unknown as Promise<{ ok: boolean }>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
export class ApiError extends Error {
|
|
||||||
statusCode: number;
|
|
||||||
|
|
||||||
constructor(message: string, statusCode: number) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API base URL resolution.
|
|
||||||
*
|
|
||||||
* Default: same-origin — the production nginx (gmw-proxy) proxies /api/* to
|
|
||||||
* the backend, so no cross-origin config is needed. For local dev against a
|
|
||||||
* remote deployment, set NEXT_PUBLIC_API_URL (e.g. https://imphnen.asepharyana.my.id).
|
|
||||||
*/
|
|
||||||
function getBaseUrl(): string {
|
|
||||||
const override =
|
|
||||||
typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL : "";
|
|
||||||
if (override) return override.replace(/\/+$/, "");
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return "";
|
|
||||||
|
|
||||||
const protocol = window.location.protocol.replace(":", "");
|
|
||||||
const port = window.location.port;
|
|
||||||
return `${protocol}://${window.location.hostname}${port ? `:${port}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiRequest<T>(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
headers?: Record<string, string>,
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${getBaseUrl()}${path}`;
|
|
||||||
|
|
||||||
const finalHeaders: Record<string, string> = { ...(headers ?? {}) };
|
|
||||||
if (body !== undefined) {
|
|
||||||
finalHeaders["Content-Type"] ??= "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: finalHeaders,
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status >= 400) {
|
|
||||||
const text = await response.text().catch(() => "");
|
|
||||||
throw new ApiError(text || `HTTP ${response.status}`, response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 204 No Content (e.g., DELETE)
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string, headers?: Record<string, string>) =>
|
|
||||||
apiRequest<T>("GET", path, undefined, headers),
|
|
||||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
|
||||||
apiRequest<T>("POST", path, body, headers),
|
|
||||||
delete: <T>(path: string, headers?: Record<string, string>) =>
|
|
||||||
apiRequest<T>("DELETE", path, undefined, headers),
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { AppConfig } from "@/lib/types";
|
import type { AppConfig } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
get: () => api.get<AppConfig>("/api/config"),
|
get: () => trpc.config.get.query() as unknown as Promise<AppConfig>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type {
|
import type {
|
||||||
DashboardActivity,
|
DashboardActivity,
|
||||||
DashboardChannelDetail,
|
DashboardChannelDetail,
|
||||||
@@ -8,44 +9,47 @@ import type {
|
|||||||
TopReactedMessage,
|
TopReactedMessage,
|
||||||
TopReactor,
|
TopReactor,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
|
getStats: () =>
|
||||||
|
trpc.dashboard.stats.query() as unknown as Promise<DashboardStats>,
|
||||||
|
|
||||||
getActivity: (days = 14) =>
|
getActivity: (days = 14) =>
|
||||||
api.get<DashboardActivity>(`/api/dashboard/activity?days=${days}`),
|
trpc.dashboard.activity.query({
|
||||||
|
days,
|
||||||
|
}) as unknown as Promise<DashboardActivity>,
|
||||||
|
|
||||||
listUsers: (limit?: number, cursor?: string, search?: string) => {
|
listUsers: (limit?: number, cursor?: string, search?: string) =>
|
||||||
const params = new URLSearchParams();
|
trpc.dashboard.users.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
limit,
|
||||||
if (cursor) params.set("cursor", cursor);
|
cursor,
|
||||||
if (search) params.set("search", search);
|
search,
|
||||||
const qs = params.toString();
|
}) as unknown as Promise<PaginatedUsers>,
|
||||||
return api.get<PaginatedUsers>(`/api/dashboard/users${qs ? `?${qs}` : ""}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserDetail: (userId: string) =>
|
getUserDetail: (userId: string) =>
|
||||||
api.get<DashboardUserDetail>(`/api/dashboard/users/${userId}`),
|
trpc.dashboard.userDetail.query({
|
||||||
|
userId,
|
||||||
|
}) as unknown as Promise<DashboardUserDetail>,
|
||||||
|
|
||||||
listChannels: (limit?: number, search?: string, guildId?: string) => {
|
listChannels: (limit?: number, search?: string, guildId?: string) =>
|
||||||
const params = new URLSearchParams();
|
trpc.dashboard.channels.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
limit,
|
||||||
if (search) params.set("search", search);
|
search,
|
||||||
// Backend reads req.query.guild_id (snake_case) — see createDashboardRouter in dashboard.routes.ts
|
guildId,
|
||||||
if (guildId) params.set("guild_id", guildId);
|
}) as unknown as Promise<PaginatedChannels>,
|
||||||
const qs = params.toString();
|
|
||||||
return api.get<PaginatedChannels>(
|
|
||||||
`/api/dashboard/channels${qs ? `?${qs}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
getChannelDetail: (channelId: string) =>
|
getChannelDetail: (channelId: string) =>
|
||||||
api.get<DashboardChannelDetail>(`/api/dashboard/channels/${channelId}`),
|
trpc.dashboard.channelDetail.query({
|
||||||
|
channelId,
|
||||||
|
}) as unknown as Promise<DashboardChannelDetail>,
|
||||||
|
|
||||||
getTopReactions: (limit = 20) =>
|
getTopReactions: (limit = 20) =>
|
||||||
api.get<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`),
|
trpc.dashboard.reactions.query({ limit }) as unknown as Promise<
|
||||||
|
TopReactedMessage[]
|
||||||
|
>,
|
||||||
|
|
||||||
getTopReactors: (limit = 20) =>
|
getTopReactors: (limit = 20) =>
|
||||||
api.get<TopReactor[]>(`/api/dashboard/reactors?limit=${limit}`),
|
trpc.dashboard.reactors.query({ limit }) as unknown as Promise<
|
||||||
|
TopReactor[]
|
||||||
|
>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
// tRPC client is browser-only (wsLink). Re-export it for convenience / for any
|
||||||
|
// code that wants to call tRPC directly instead of going through the api/*
|
||||||
|
// wrappers. Server-side RSC data lives in ./server (httpLink).
|
||||||
|
export { trpc } from "../trpc/client";
|
||||||
export { chatbotApi } from "./chatbot";
|
export { chatbotApi } from "./chatbot";
|
||||||
export { ApiError, api, apiRequest } from "./client";
|
|
||||||
export { configApi } from "./config";
|
export { configApi } from "./config";
|
||||||
export { dashboardApi } from "./dashboard";
|
export { dashboardApi } from "./dashboard";
|
||||||
export { mediaApi } from "./media";
|
export { mediaApi } from "./media";
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const mediaApi = {
|
export const mediaApi = {
|
||||||
getStatus: () => api.get<MediaState>("/api/media/status"),
|
getStatus: () => trpc.media.status.query() as unknown as Promise<MediaState>,
|
||||||
queue: (source: string, mode: string) =>
|
queue: (source: string, mode: string) =>
|
||||||
api.post<MediaState>("/api/media/queue", { source, mode }),
|
trpc.media.queue.mutate({ source, mode }) as unknown as Promise<MediaState>,
|
||||||
skip: () => api.post<MediaState>("/api/media/skip", {}),
|
skip: () => trpc.media.skip.mutate() as unknown as Promise<MediaState>,
|
||||||
stop: () => api.post<MediaState>("/api/media/stop", {}),
|
stop: () => trpc.media.stop.mutate() as unknown as Promise<MediaState>,
|
||||||
loop: (loop: boolean) => api.post<MediaState>("/api/media/loop", { loop }),
|
loop: (loop: boolean) =>
|
||||||
|
trpc.media.loop.mutate({ loop }) as unknown as Promise<MediaState>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const messagesApi = {
|
export const messagesApi = {
|
||||||
list: (
|
list: (
|
||||||
@@ -7,69 +7,59 @@ export const messagesApi = {
|
|||||||
limit?: number,
|
limit?: number,
|
||||||
channelId?: string,
|
channelId?: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
) => {
|
) =>
|
||||||
// Backend messageQuerySchema expects camelCase guildId (see messages.schema.ts)
|
trpc.messages.list.query({
|
||||||
const params = new URLSearchParams({ guildId });
|
guildId,
|
||||||
if (limit) params.set("limit", String(limit));
|
limit,
|
||||||
if (channelId) params.set("channelId", channelId);
|
channelId,
|
||||||
if (cursor) params.set("cursor", cursor);
|
cursor,
|
||||||
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
|
}) as unknown as Promise<{
|
||||||
`/api/messages?${params}`,
|
data: MessageRecord[];
|
||||||
);
|
nextCursor: string | null;
|
||||||
},
|
}>,
|
||||||
|
|
||||||
getByChannel: (channelId: string, limit?: number, cursor?: string) => {
|
getByChannel: (channelId: string, limit?: number, cursor?: string) =>
|
||||||
const params = new URLSearchParams();
|
trpc.messages.byChannel.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
channelId,
|
||||||
if (cursor) params.set("cursor", cursor);
|
query: { channelId, limit, cursor },
|
||||||
const qs = params.toString();
|
}) as unknown as Promise<{
|
||||||
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
|
data: MessageRecord[];
|
||||||
`/api/messages/${channelId}${qs ? `?${qs}` : ""}`,
|
nextCursor: string | null;
|
||||||
);
|
}>,
|
||||||
},
|
|
||||||
|
|
||||||
getDetail: (id: string) =>
|
getDetail: (id: string) =>
|
||||||
api.get<MessageRecord>(`/api/messages/detail/${id}`),
|
trpc.messages.detail.query({ id }) as unknown as Promise<MessageRecord>,
|
||||||
|
|
||||||
getImages: (guildId: string, limit?: number) => {
|
getImages: (guildId: string, limit?: number) =>
|
||||||
// Backend reads req.query.guildId (camelCase) — see handleGetImageMessages in messages.controller.ts
|
trpc.messages.images.query({ guildId, limit }) as unknown as Promise<{
|
||||||
const params = new URLSearchParams({ guildId });
|
data: MessageRecord[];
|
||||||
if (limit) params.set("limit", String(limit));
|
nextCursor: string | null;
|
||||||
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
|
}>,
|
||||||
`/api/messages/images?${params}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
getAttachments: (
|
getAttachments: (
|
||||||
channelId: string,
|
channelId: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
messageId?: string,
|
messageId?: string,
|
||||||
) => {
|
) =>
|
||||||
const params = new URLSearchParams();
|
trpc.messages.attachmentsByChannel.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
channelId,
|
||||||
if (cursor) params.set("cursor", cursor);
|
query: { channelId, limit, cursor, messageId },
|
||||||
if (messageId) params.set("messageId", messageId);
|
}) as unknown as Promise<{
|
||||||
const qs = params.toString();
|
data: AttachmentRecord[];
|
||||||
return api.get<{ data: AttachmentRecord[]; nextCursor: string | null }>(
|
nextCursor: string | null;
|
||||||
`/api/messages/${channelId}/attachments${qs ? `?${qs}` : ""}`,
|
}>,
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
getReview: (limit?: number, channelId?: string) => {
|
getReview: (limit?: number, channelId?: string) =>
|
||||||
const params = new URLSearchParams();
|
trpc.messages.review.query({ limit, channelId }) as unknown as Promise<{
|
||||||
if (limit) params.set("limit", String(limit));
|
results: MessageRecord[];
|
||||||
if (channelId) params.set("channelId", channelId);
|
limit: number;
|
||||||
return api.get<{ results: MessageRecord[]; limit: number; cursor: null }>(
|
cursor: null;
|
||||||
`/api/review?${params}`,
|
}>,
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
search: (query: string, limit?: number) => {
|
// Analysis search (formerly /api/analysis/search → tRPC analysis.search)
|
||||||
const params = new URLSearchParams({ q: query });
|
search: (q: string, limit?: number) =>
|
||||||
if (limit) params.set("limit", String(limit));
|
trpc.analysis.search.query({ q, limit }) as unknown as Promise<{
|
||||||
return api.get<{ results: MessageRecord[] }>(
|
results: MessageRecord[];
|
||||||
`/api/analysis/search?${params}`,
|
}>,
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
|
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const moderationApi = {
|
export const moderationApi = {
|
||||||
getStats: () => api.get<ModerationStats>("/api/moderation/stats"),
|
getStats: () =>
|
||||||
|
trpc.moderation.stats.query() as unknown as Promise<ModerationStats>,
|
||||||
|
|
||||||
listActions: (
|
listActions: (
|
||||||
limit?: number,
|
limit?: number,
|
||||||
status?: string,
|
status?: string,
|
||||||
actionType?: string,
|
actionType?: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
) => {
|
) =>
|
||||||
const params = new URLSearchParams();
|
trpc.moderation.actions.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
limit,
|
||||||
if (status) params.set("status", status);
|
status,
|
||||||
if (actionType) params.set("actionType", actionType);
|
actionType,
|
||||||
if (cursor) params.set("cursor", cursor);
|
cursor,
|
||||||
const qs = params.toString();
|
}) as unknown as Promise<PaginatedModerationActions>,
|
||||||
return api.get<PaginatedModerationActions>(
|
|
||||||
`/api/moderation/actions${qs ? `?${qs}` : ""}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { PaginatedRecordings } from "@/lib/types";
|
import type { PaginatedRecordings } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const recordingsApi = {
|
export const recordingsApi = {
|
||||||
list: (
|
list: (
|
||||||
@@ -7,15 +7,16 @@ export const recordingsApi = {
|
|||||||
channelId?: string,
|
channelId?: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
) => {
|
) =>
|
||||||
const params = new URLSearchParams();
|
trpc.recordings.list.query({
|
||||||
if (limit) params.set("limit", String(limit));
|
limit,
|
||||||
if (channelId) params.set("channelId", channelId);
|
channelId,
|
||||||
if (userId) params.set("userId", userId);
|
userId,
|
||||||
if (cursor) params.set("cursor", cursor);
|
cursor,
|
||||||
const qs = params.toString();
|
}) as unknown as Promise<PaginatedRecordings>,
|
||||||
return api.get<PaginatedRecordings>(`/api/recordings${qs ? `?${qs}` : ""}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: (id: string) => api.delete<{ ok: boolean }>(`/api/recordings/${id}`),
|
delete: (id: string) =>
|
||||||
|
trpc.recordings.delete.mutate({ id }) as unknown as Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
}>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Server-only data layer.
|
* Server-only data layer (React Server Components / route handlers).
|
||||||
*
|
*
|
||||||
* These fetchers run exclusively on the Next.js server (React Server
|
* The dashboard is fully tRPC-native: this module talks to the backend's
|
||||||
* Components / route handlers). They call the backend over HTTP directly
|
* tRPC HTTP endpoint (/trpc) via an httpLink client — the same appRouter the
|
||||||
* (`GMW_BACKEND_URL`), so the browser never needs a client round-trip for the
|
* browser reaches over WebSocket. No legacy REST `/api/*` is used.
|
||||||
* initial page data — the first paint is server-rendered.
|
|
||||||
*
|
*
|
||||||
* Never import this module from a client component. Browser code should keep
|
* The client is loosely typed (see ./types → TRPCClient); results are asserted
|
||||||
* using `@/lib/api/client` (same-origin via the reverse proxy) for live ops.
|
* to the frontend's local types at each call site.
|
||||||
|
*
|
||||||
|
* Never import this module from a client component. Browser code uses
|
||||||
|
* `@/lib/trpc/client` (wsLink) via the `@/lib/api/*` wrappers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DashboardActivity,
|
DashboardActivity,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
Guild,
|
Guild,
|
||||||
MediaState,
|
MediaState,
|
||||||
ModerationAction,
|
|
||||||
ModerationStats,
|
ModerationStats,
|
||||||
|
PaginatedModerationActions,
|
||||||
PaginatedRecordings,
|
PaginatedRecordings,
|
||||||
VoiceStatus,
|
VoiceStatus,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import type { TRPCClient } from "../trpc/types";
|
||||||
|
|
||||||
const BACKEND_URL =
|
const BACKEND_URL =
|
||||||
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
|
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
|
||||||
|
|
||||||
|
let _client: TRPCClient | null = null;
|
||||||
|
function serverTrpc(): TRPCClient {
|
||||||
|
if (!_client) {
|
||||||
|
_client = createTRPCClient({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${BACKEND_URL}/trpc`,
|
||||||
|
fetch(url, init) {
|
||||||
|
return fetch(url, { ...init, cache: "no-store" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}) as unknown as TRPCClient;
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiServerError extends Error {
|
export class ApiServerError extends Error {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
constructor(message: string, statusCode: number) {
|
constructor(message: string, statusCode: number) {
|
||||||
@@ -34,102 +55,48 @@ export class ApiServerError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverFetch<T>(
|
|
||||||
path: string,
|
|
||||||
init?: { timeoutMs?: number },
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${BACKEND_URL}${path}`;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(
|
|
||||||
() => controller.abort(),
|
|
||||||
init?.timeoutMs ?? 8_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(url, {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new ApiServerError(text || `HTTP ${res.status}`, res.status);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Dashboard ----
|
// ---- Dashboard ----
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
return serverFetch<DashboardStats>("/api/dashboard/stats");
|
return serverTrpc().dashboard.stats.query() as unknown as Promise<DashboardStats>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivity(days = 14): Promise<DashboardActivity> {
|
export async function getActivity(days = 14): Promise<DashboardActivity> {
|
||||||
return serverFetch<DashboardActivity>(`/api/dashboard/activity?days=${days}`);
|
return serverTrpc().dashboard.activity.query({
|
||||||
|
days,
|
||||||
|
}) as unknown as Promise<DashboardActivity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Media ----
|
// ---- Media ----
|
||||||
|
|
||||||
export async function getMediaStatus(): Promise<MediaState> {
|
export async function getMediaStatus(): Promise<MediaState> {
|
||||||
return serverFetch<MediaState>("/api/media/status");
|
return serverTrpc().media.status.query() as unknown as Promise<MediaState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Config ----
|
// ---- Config ----
|
||||||
|
|
||||||
export async function getConfig(): Promise<AppConfig> {
|
export async function getConfig(): Promise<AppConfig> {
|
||||||
return serverFetch<AppConfig>("/api/config");
|
return serverTrpc().config.get.query() as unknown as Promise<AppConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Moderation ----
|
// ---- Moderation ----
|
||||||
|
|
||||||
export async function getModerationStats(): Promise<ModerationStats> {
|
export async function getModerationStats(): Promise<ModerationStats> {
|
||||||
return serverFetch<ModerationStats>("/api/moderation/stats");
|
return serverTrpc().moderation.stats.query() as unknown as Promise<ModerationStats>;
|
||||||
}
|
}
|
||||||
|
export async function getModerationActions(limit = 100) {
|
||||||
export async function getModerationActions(
|
const res = (await serverTrpc().moderation.actions.query({
|
||||||
limit = 100,
|
limit,
|
||||||
): Promise<ModerationAction[]> {
|
})) as unknown as PaginatedModerationActions;
|
||||||
const res = await serverFetch<{ data: ModerationAction[] }>(
|
|
||||||
`/api/moderation/actions?limit=${limit}`,
|
|
||||||
);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Voice ----
|
// ---- Voice ----
|
||||||
|
|
||||||
export async function getGuilds(): Promise<Guild[]> {
|
export async function getGuilds(): Promise<Guild[]> {
|
||||||
return serverFetch<Guild[]>("/api/guilds");
|
return serverTrpc().voice.guilds.query() as unknown as Promise<Guild[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
return serverFetch<VoiceStatus>("/api/voice/status");
|
return serverTrpc().voice.status.query() as unknown as Promise<VoiceStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Recordings ----
|
// ---- Recordings ----
|
||||||
|
|
||||||
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
|
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
|
||||||
return serverFetch<PaginatedRecordings>(`/api/recordings?limit=${limit}`);
|
return serverTrpc().recordings.list.query({
|
||||||
}
|
limit,
|
||||||
|
}) as unknown as Promise<PaginatedRecordings>;
|
||||||
// ---- Messages ----
|
|
||||||
|
|
||||||
export interface MessagePageResult {
|
|
||||||
data: import("@/lib/types").MessageRecord[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMessages(
|
|
||||||
guildId: string,
|
|
||||||
channelId?: string,
|
|
||||||
cursor?: string,
|
|
||||||
): Promise<MessagePageResult> {
|
|
||||||
const params = new URLSearchParams({ guildId });
|
|
||||||
if (channelId) params.set("channelId", channelId);
|
|
||||||
if (cursor) params.set("cursor", cursor);
|
|
||||||
return serverFetch<MessagePageResult>(`/api/messages?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { UiState } from "@/lib/types";
|
import type { UiState } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const uiStateApi = {
|
export const uiStateApi = {
|
||||||
get: () => api.get<UiState>("/api/ui-state"),
|
get: () => trpc.uiState.get.query() as unknown as Promise<UiState>,
|
||||||
|
|
||||||
save: (state: UiState) => api.post<{ ok: boolean }>("/api/ui-state", state),
|
save: (state: UiState) =>
|
||||||
|
trpc.uiState.update.mutate(state) as unknown as Promise<{ ok: boolean }>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
|
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
|
||||||
import { api } from "./client";
|
|
||||||
|
|
||||||
export const voiceApi = {
|
export const voiceApi = {
|
||||||
// Guilds
|
// Guilds
|
||||||
getGuilds: () => api.get<Guild[]>("/api/guilds"),
|
getGuilds: () => trpc.voice.guilds.query() as unknown as Promise<Guild[]>,
|
||||||
getTextChannels: (guildId: string) =>
|
getTextChannels: (guildId: string) =>
|
||||||
api.get<Channel[]>(`/api/guilds/${guildId}/channels`),
|
trpc.voice.textChannels.query({ guildId }) as unknown as Promise<Channel[]>,
|
||||||
getVoiceChannels: (guildId: string) =>
|
getVoiceChannels: (guildId: string) =>
|
||||||
api.get<Channel[]>(`/api/guilds/${guildId}/voice-channels`),
|
trpc.voice.voiceChannels.query({
|
||||||
|
guildId,
|
||||||
|
}) as unknown as Promise<Channel[]>,
|
||||||
|
|
||||||
// Voice connection
|
// Voice connection
|
||||||
getStatus: () => api.get<VoiceStatus>("/api/voice/status"),
|
getStatus: () => trpc.voice.status.query() as unknown as Promise<VoiceStatus>,
|
||||||
connect: (guildId: string, channelId: string) =>
|
connect: (guildId: string, channelId: string) =>
|
||||||
api.post<VoiceStatus>("/api/voice/connect", { guildId, channelId }),
|
trpc.voice.connect.mutate({
|
||||||
disconnect: () => api.post<VoiceStatus>("/api/voice/disconnect", {}),
|
guildId,
|
||||||
|
channelId,
|
||||||
|
}) as unknown as Promise<VoiceStatus>,
|
||||||
|
disconnect: () =>
|
||||||
|
trpc.voice.disconnect.mutate() as unknown as Promise<VoiceStatus>,
|
||||||
sendCommand: (command: string) =>
|
sendCommand: (command: string) =>
|
||||||
api.post<{ success: boolean; command: string }>("/api/voice/command", {
|
trpc.voice.command.mutate({ command }) as unknown as Promise<unknown>,
|
||||||
command,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createTRPCClient, createWSClient, wsLink } from "@trpc/client";
|
||||||
|
import type { TRPCClient } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket URL for the tRPC data RPC endpoint (/trpc), served by the backend
|
||||||
|
* on the same host as the page (nginx proxies it). Upgrades http→ws and
|
||||||
|
* derives wss:// when the page is served over https.
|
||||||
|
*/
|
||||||
|
function resolveWsUrl(): string {
|
||||||
|
if (typeof window === "undefined") return "ws://localhost/trpc";
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
return `${proto}://${window.location.host}/trpc`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tRPC client over WebSocket (browser only). `createTRPCClient` (no router
|
||||||
|
* generic) is an untyped client; we assert it into the loose `TRPCClient`
|
||||||
|
* shape so `.dashboard.stats.query(...)` etc. typecheck. See ./types for why
|
||||||
|
* the client shape is intentionally untyped.
|
||||||
|
*/
|
||||||
|
const wsClient =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? null
|
||||||
|
: createWSClient({ url: resolveWsUrl() });
|
||||||
|
|
||||||
|
export const trpc: TRPCClient = wsClient
|
||||||
|
? (createTRPCClient({
|
||||||
|
links: [wsLink({ client: wsClient })],
|
||||||
|
}) as unknown as TRPCClient)
|
||||||
|
: // SSR fallback — api/* callers are client components, never run server-side.
|
||||||
|
(undefined as unknown as TRPCClient);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Loosely-typed tRPC client shape shared by the browser (wsLink) and
|
||||||
|
* server-side (httpLink) clients. The real router lives on the backend; we do
|
||||||
|
* NOT import its type here (coupling the FE typecheck to the BE source tree
|
||||||
|
* and its `@/` alias layout is fragile). Instead we describe the minimal shape
|
||||||
|
* the api/* wrappers rely on: any path segment yields an object with `query`
|
||||||
|
* and `mutate`, and arbitrary further nesting is allowed. Leaf results are
|
||||||
|
* asserted to the frontend's local types inside the api/* wrappers.
|
||||||
|
*/
|
||||||
|
export type TRPCClient = {
|
||||||
|
[k: string]: TRPCClient;
|
||||||
|
} & {
|
||||||
|
query(input?: unknown): Promise<unknown>;
|
||||||
|
mutate(input?: unknown): Promise<unknown>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user