flake.nix only rewrote @/ aliases but left extensionless relative imports (./router) in compiled dist/. node dist/index.js (how prod runs) cannot resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared scripts/fix-imports.mjs that appends .js to extensionless relative imports and rewrites @/ aliases, and wire it into backend + discord-gateway build phases. Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/ media/chatbot/analysis) end-to-end against Postgres + Redis. next build passes with the oRPC client + partysocket.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { os } from "@orpc/server";
|
|
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";
|
|
|
|
// ── Dashboard ────────────────────────────────────────────────────
|
|
const dashboardRouter = {
|
|
stats: os.handler(() => dashboardService.getStats()),
|
|
activity: os
|
|
.input(z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }))
|
|
.handler(({ input }) => dashboardService.getActivity(input.days)),
|
|
users: os
|
|
.input(
|
|
z.object({
|
|
limit: z.coerce.number().int().positive().default(20),
|
|
cursor: z.string().optional(),
|
|
search: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
dashboardService.listUsers({
|
|
limit: input.limit,
|
|
cursor: input.cursor,
|
|
search: input.search,
|
|
}),
|
|
),
|
|
userDetail: os
|
|
.input(z.object({ userId: z.string() }))
|
|
.handler(({ input }) => dashboardService.getUserDetail(input.userId)),
|
|
channels: os
|
|
.input(
|
|
z.object({
|
|
limit: z.coerce.number().int().positive().default(20),
|
|
search: z.string().optional(),
|
|
guildId: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
dashboardService.listChannels({
|
|
limit: input.limit,
|
|
search: input.search,
|
|
guildId: input.guildId,
|
|
}),
|
|
),
|
|
channelDetail: os
|
|
.input(z.object({ channelId: z.string() }))
|
|
.handler(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
|
reactions: os
|
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
|
.handler(({ input }) => dashboardService.getTopReactions(input.limit)),
|
|
reactors: os
|
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
|
.handler(({ input }) => dashboardService.getTopReactors(input.limit)),
|
|
};
|
|
|
|
// ── Messages ─────────────────────────────────────────────────────
|
|
const messagesRouter = {
|
|
list: os
|
|
.input(messageQuerySchema)
|
|
.handler(({ input }) => messagesService.listMessages(input)),
|
|
byChannel: os
|
|
.input(
|
|
z.object({
|
|
channelId: z.string(),
|
|
query: messageQuerySchema,
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
messagesService.getMessagesByChannel(input.channelId, input.query),
|
|
),
|
|
detail: os
|
|
.input(z.object({ id: z.string() }))
|
|
.handler(({ input }) => messagesService.getMessageById(input.id)),
|
|
images: os
|
|
.input(
|
|
z.object({
|
|
guildId: z.string(),
|
|
limit: z.coerce.number().int().positive().default(50),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
messagesService.getImageMessages(input.guildId, input.limit),
|
|
),
|
|
attachmentsByChannel: os
|
|
.input(
|
|
z.object({
|
|
channelId: z.string(),
|
|
query: messageQuerySchema,
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
|
),
|
|
review: os
|
|
.input(
|
|
z.object({
|
|
limit: z.coerce.number().int().positive().default(20),
|
|
channelId: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(async ({ input }) => {
|
|
const rows = await messagesService.getReviewMessages(
|
|
input.channelId,
|
|
input.limit,
|
|
);
|
|
return { results: rows, limit: input.limit, cursor: null };
|
|
}),
|
|
};
|
|
|
|
// ── Moderation ───────────────────────────────────────────────────
|
|
const moderationRouter = {
|
|
stats: os.handler(() => moderationService.getStats()),
|
|
actions: os
|
|
.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(),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
moderationService.listActions({
|
|
limit: input.limit,
|
|
status: input.status,
|
|
actionType: input.actionType,
|
|
cursor: input.cursor,
|
|
}),
|
|
),
|
|
};
|
|
|
|
// ── Media ────────────────────────────────────────────────────────
|
|
const mediaRouter = {
|
|
status: os.handler(() => getStatus()),
|
|
queue: os
|
|
.input(mediaQueueSchema)
|
|
.handler(async ({ input }) => {
|
|
await queue(input.source, input.mode);
|
|
return getStatus();
|
|
}),
|
|
skip: os.handler(async () => {
|
|
await skip();
|
|
return getStatus();
|
|
}),
|
|
stop: os.handler(async () => {
|
|
await stop();
|
|
return getStatus();
|
|
}),
|
|
loop: os
|
|
.input(mediaLoopSchema)
|
|
.handler(async ({ input }) => {
|
|
await setLoop(input.loop);
|
|
return getStatus();
|
|
}),
|
|
};
|
|
|
|
// ── Voice ─────────────────────────────────────────────────────────
|
|
const voiceRouter = {
|
|
guilds: os.handler(() => getGuilds()),
|
|
textChannels: os
|
|
.input(z.object({ guildId: z.string() }))
|
|
.handler(({ input }) => getTextChannels(input.guildId)),
|
|
voiceChannels: os
|
|
.input(z.object({ guildId: z.string() }))
|
|
.handler(({ input }) => getVoiceChannels(input.guildId)),
|
|
status: os.handler(() => getVoiceStatus()),
|
|
connect: os
|
|
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
|
.handler(async ({ input }) => {
|
|
await connectVoice(input.guildId, input.channelId);
|
|
return getVoiceStatus();
|
|
}),
|
|
disconnect: os.handler(async () => {
|
|
await disconnectVoice();
|
|
return getVoiceStatus();
|
|
}),
|
|
command: os
|
|
.input(z.object({ command: z.string().min(1) }))
|
|
.handler(async ({ input }) => {
|
|
await publishCommandNoReply(input.command);
|
|
return { success: true, command: input.command };
|
|
}),
|
|
};
|
|
|
|
// ── Recordings ───────────────────────────────────────────────────
|
|
const recordingsRouter = {
|
|
list: os
|
|
.input(
|
|
z.object({
|
|
limit: z.coerce.number().int().positive().default(50),
|
|
channelId: z.string().optional(),
|
|
userId: z.string().optional(),
|
|
cursor: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
recordingsService.getRecent(input.limit, {
|
|
channelId: input.channelId,
|
|
userId: input.userId,
|
|
cursor: input.cursor,
|
|
}),
|
|
),
|
|
delete: os
|
|
.input(z.object({ id: z.string() }))
|
|
.handler(async ({ input }) => {
|
|
await recordingsService.deleteById(input.id);
|
|
return { ok: true };
|
|
}),
|
|
};
|
|
|
|
// ── Analysis (search) ──────────────────────────────────────────────
|
|
const analysisRouter = {
|
|
search: os
|
|
.input(
|
|
z.object({
|
|
q: z.string().default(""),
|
|
channelId: z.string().optional(),
|
|
limit: z.coerce.number().int().positive().default(20),
|
|
}),
|
|
)
|
|
.handler(({ input }) =>
|
|
analysisService.search({
|
|
q: input.q,
|
|
channelId: input.channelId,
|
|
limit: input.limit,
|
|
}),
|
|
),
|
|
};
|
|
|
|
// ── Chatbot ───────────────────────────────────────────────────────
|
|
const chatbotRouter = {
|
|
chat: os
|
|
.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(),
|
|
}),
|
|
)
|
|
.handler(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: os
|
|
.input(
|
|
z.object({
|
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
|
userId: z.string().optional(),
|
|
}),
|
|
)
|
|
.handler(async ({ input }) => {
|
|
const userId = input.userId ?? "anonymous";
|
|
const history = await chatbotService.getChatHistory(userId, input.limit);
|
|
return { history, total: history.length };
|
|
}),
|
|
clearHistory: os
|
|
.input(z.object({ userId: z.string().optional() }))
|
|
.handler(async ({ input }) => {
|
|
const userId = input.userId ?? "anonymous";
|
|
await chatbotService.clearChatHistory(userId);
|
|
return { ok: true };
|
|
}),
|
|
};
|
|
|
|
// ── Config (public dashboard config snapshot) ──────────────────────
|
|
const configRouter = {
|
|
get: os.handler(() => ({
|
|
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 = {
|
|
get: os.handler(() => uiStateService.getState()),
|
|
update: os
|
|
.input(z.record(z.string(), z.unknown()))
|
|
.handler(({ input }) => uiStateService.updateState(input)),
|
|
};
|
|
|
|
// ── Root router ───────────────────────────────────────────────────
|
|
export const appRouter = {
|
|
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;
|