feat(shared): reorder AppError constructor to (message, code, status), add singleton logger, retry and TTL cache utilities
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bete/shared": "workspace:*",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRecordingsRouter } from "../modules/recordings/recordings.routes.
|
||||
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
|
||||
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
||||
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { errorHandler } from "../shared/middlewares/index.js";
|
||||
|
||||
const logger = createChildLogger("http.app");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { initializeDatabase } from "../shared/database/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { createHttpApp } from "./app.js";
|
||||
import { createWebSocketServer } from "../ws/server.js";
|
||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startHttpServer } from "./http/server.js";
|
||||
import { createChildLogger } from "./shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const logger = createChildLogger("backend");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { analysisService } from "./analysis.service.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("analysis.service");
|
||||
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import {
|
||||
asyncHandler,
|
||||
requireParam,
|
||||
} from "../../shared/middlewares/index.js";
|
||||
import { analyticsQuerySchema } from "./analytics.schema.js";
|
||||
import { analyticsService } from "./analytics.service.js";
|
||||
|
||||
const logger = createChildLogger("analytics.controller");
|
||||
|
||||
function requireQueryString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing query parameter: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleGetOverview(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -32,7 +28,7 @@ export function handleGetDailyTrend(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
const guildId = requireParam(req.query.guildId, "query parameter", "guildId");
|
||||
const hours = req.query.hours ? Number(req.query.hours) : 24;
|
||||
logger.debug({ guildId, hours }, "Handling get daily trend");
|
||||
const result = await analyticsService.getDailyTrend(guildId, hours);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("analytics.repository");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { ForbiddenError, ValidationError } from "../../shared/errors/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { ForbiddenError, ValidationError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { analyticsRepository } from "./analytics.repository.js";
|
||||
import type { AnalyticsQuery } from "./analytics.schema.js";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { UnauthorizedError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
|
||||
const logger = createChildLogger("auth.routes");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("health.repository");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { healthRepository } from "./health.repository.js";
|
||||
|
||||
const logger = createChildLogger("health.service");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { queue, skip, stop, setVolume, getStatus } from "./media.service.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
const logger = createChildLogger("media.service");
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import {
|
||||
asyncHandler,
|
||||
requireParam,
|
||||
} from "../../shared/middlewares/index.js";
|
||||
import { messageQuerySchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
const logger = createChildLogger("messages.controller");
|
||||
|
||||
function requireRouteParam(
|
||||
value: string | string[] | undefined,
|
||||
name: string,
|
||||
): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing route parameter: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleListMessages(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -35,7 +28,7 @@ export function handleGetMessagesByChannel(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireRouteParam(req.params.channelId, "channelId");
|
||||
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
||||
@@ -49,7 +42,7 @@ export function handleGetMessageById(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const id = requireRouteParam(req.params.id, "id");
|
||||
const id = requireParam(req.params.id, "route parameter", "id");
|
||||
logger.debug({ id }, "Handling get message by ID");
|
||||
const result = await messagesService.getMessageById(id);
|
||||
res.json(result);
|
||||
@@ -62,7 +55,7 @@ export function handleGetAttachmentsByChannel(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireRouteParam(req.params.channelId, "channelId");
|
||||
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
||||
const result = await messagesService.getAttachmentsByChannel(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type {
|
||||
MessageCreate,
|
||||
MessageQuery,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
handleGetAttachmentsByChannel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NotFoundError, ValidationError } from "../../shared/errors/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { NotFoundError, ValidationError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { messagesRepository } from "./messages.repository.js";
|
||||
import type { MessageQuery } from "./messages.schema.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { recordingsService } from "./recordings.service.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("recordings.service");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { uiStateService } from "./ui-state.service.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("ui-state.service");
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import {
|
||||
publishCommand,
|
||||
readRedisStatus,
|
||||
} from "../../shared/redis/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("voice.service");
|
||||
|
||||
@@ -24,109 +26,14 @@ export interface VoiceStatus {
|
||||
activeChannelName: string | null;
|
||||
}
|
||||
|
||||
interface CommandReply {
|
||||
id: string;
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// --- Redis command client ---
|
||||
let commandRedis: Redis | null = null;
|
||||
let statusRedis: Redis | null = null;
|
||||
|
||||
function getCommandRedis(): Redis {
|
||||
if (!commandRedis) {
|
||||
commandRedis = config.REDIS_URL
|
||||
? new Redis(config.REDIS_URL, { keyPrefix: "" })
|
||||
: new Redis({
|
||||
host: config.REDIS_HOST,
|
||||
port: config.REDIS_PORT,
|
||||
keyPrefix: "",
|
||||
});
|
||||
}
|
||||
return commandRedis;
|
||||
}
|
||||
|
||||
function getStatusRedis(): Redis {
|
||||
if (!statusRedis) {
|
||||
statusRedis = config.REDIS_URL
|
||||
? new Redis(config.REDIS_URL, { keyPrefix: "" })
|
||||
: new Redis({
|
||||
host: config.REDIS_HOST,
|
||||
port: config.REDIS_PORT,
|
||||
keyPrefix: "",
|
||||
});
|
||||
}
|
||||
return statusRedis;
|
||||
}
|
||||
|
||||
async function sendCommand<T = unknown>(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 10000,
|
||||
): Promise<T | null> {
|
||||
const redis = getCommandRedis();
|
||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const replyChannel = `backend:command:reply:${id}`;
|
||||
|
||||
return new Promise<T | null>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
redis.unsubscribe(replyChannel).catch(() => {});
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
redis.subscribe(replyChannel, (err) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const handler = (_ch: string, msg: string) => {
|
||||
if (_ch === replyChannel) {
|
||||
clearTimeout(timer);
|
||||
redis.unsubscribe(replyChannel).catch(() => {});
|
||||
try {
|
||||
const reply: CommandReply = JSON.parse(msg);
|
||||
resolve(reply.success ? (reply.data as T) : null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
redis.on("message", handler);
|
||||
|
||||
redis
|
||||
.publish(
|
||||
"backend:command",
|
||||
JSON.stringify({ id, type, payload, replyChannel }),
|
||||
)
|
||||
.catch(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStatus<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const val = await getStatusRedis().get(key);
|
||||
return val ? (JSON.parse(val) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guilds — query from discord-gateway via Redis command for real names.
|
||||
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
|
||||
*/
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
const fromGateway = await sendCommand<Guild[]>("guilds:list", {});
|
||||
if (fromGateway && fromGateway.length > 0) return fromGateway;
|
||||
const reply = await publishCommand<Guild[]>("guilds:list", {});
|
||||
if (reply?.success && reply.data && reply.data.length > 0)
|
||||
return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
@@ -149,10 +56,11 @@ export async function getGuilds(): Promise<Guild[]> {
|
||||
* Falls back to database if gateway unreachable.
|
||||
*/
|
||||
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
const fromGateway = await sendCommand<Channel[]>("guilds:text-channels", {
|
||||
const reply = await publishCommand<Channel[]>("guilds:text-channels", {
|
||||
guildId,
|
||||
});
|
||||
if (fromGateway && fromGateway.length > 0) return fromGateway;
|
||||
if (reply?.success && reply.data && reply.data.length > 0)
|
||||
return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
@@ -176,16 +84,16 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
* Get voice channels — query from discord-gateway via Redis command.
|
||||
*/
|
||||
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
const channels = await sendCommand<Channel[]>("voice:channels", { guildId });
|
||||
return channels ?? [];
|
||||
const reply = await publishCommand<Channel[]>("voice:channels", { guildId });
|
||||
return reply?.success && reply.data ? reply.data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current voice connection status from Redis cache set by discord-gateway.
|
||||
*/
|
||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
const cached = await readStatus<VoiceStatus>("voice:status");
|
||||
if (cached) return cached;
|
||||
const cached = await readRedisStatus("voice:status");
|
||||
if (cached) return cached as unknown as VoiceStatus;
|
||||
return {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
@@ -201,38 +109,34 @@ export async function connectVoice(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<VoiceStatus> {
|
||||
const result = await sendCommand<VoiceStatus>("voice:connect", {
|
||||
const reply = await publishCommand<VoiceStatus>("voice:connect", {
|
||||
guildId,
|
||||
channelId,
|
||||
});
|
||||
if (result) return result;
|
||||
if (reply?.success && reply.data) return reply.data;
|
||||
|
||||
// Fallback: read from Redis status key
|
||||
const cached = await readStatus<VoiceStatus>("voice:status");
|
||||
return (
|
||||
cached ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
);
|
||||
const cached = await readRedisStatus("voice:status");
|
||||
return (cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from voice via Redis command to discord-gateway.
|
||||
*/
|
||||
export async function disconnectVoice(): Promise<VoiceStatus> {
|
||||
const result = await sendCommand<VoiceStatus>("voice:disconnect", {});
|
||||
if (result) return result;
|
||||
const reply = await publishCommand<VoiceStatus>("voice:disconnect", {});
|
||||
if (reply?.success && reply.data) return reply.data;
|
||||
|
||||
const cached = await readStatus<VoiceStatus>("voice:status");
|
||||
return (
|
||||
cached ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
);
|
||||
const cached = await readRedisStatus("voice:status");
|
||||
return (cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config/index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("database");
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number = 500,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "NOT_FOUND", 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message, "UNAUTHORIZED", 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(message, "FORBIDDEN", 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFLICT", 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
public originalError?: Error,
|
||||
) {
|
||||
super(message, "DATABASE_ERROR", 500);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFIG_ERROR", 500);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
const isDev = config.NODE_ENV === "development";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.LOG_LEVEL,
|
||||
transport: isDev
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export function createChildLogger(context: string) {
|
||||
return logger.child({ context });
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof createChildLogger>;
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { AppError, UnauthorizedError } from "../errors/index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import {
|
||||
AppError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("middleware");
|
||||
|
||||
@@ -46,5 +50,13 @@ export function asyncHandler(
|
||||
};
|
||||
}
|
||||
|
||||
// Import ValidationError for type checking
|
||||
import { ValidationError } from "../errors/index.js";
|
||||
/**
|
||||
* Validate that a value is a non-empty string, or throw a descriptive error.
|
||||
* Use for both route params and query string values.
|
||||
*/
|
||||
export function requireParam(value: unknown, kind: string, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing ${kind}: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../config/index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("redis.command-channel");
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
type BroadcastFn = (data: unknown) => void;
|
||||
type BroadcastRawFn = (type: string, data: unknown) => void;
|
||||
|
||||
// Extend globalThis with broadcast function types
|
||||
declare global {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
|
||||
var __broadcastFns:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { getCommandPublisher } from "../shared/redis/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { broadcastRaw } from "./broadcast.js";
|
||||
|
||||
const logger = createChildLogger("ws.redis-bridge");
|
||||
@@ -27,9 +28,8 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
|
||||
];
|
||||
|
||||
let subscriber: Redis | null = null;
|
||||
let publisher: Redis | null = null;
|
||||
|
||||
function createRedisInstance(): Redis {
|
||||
function createSubscriber(): Redis {
|
||||
if (config.REDIS_URL) {
|
||||
return new Redis(config.REDIS_URL, { keyPrefix: "" });
|
||||
}
|
||||
@@ -40,17 +40,6 @@ function createRedisInstance(): Redis {
|
||||
});
|
||||
}
|
||||
|
||||
function createSubscriber(): Redis {
|
||||
return createRedisInstance();
|
||||
}
|
||||
|
||||
function getPublisher(): Redis {
|
||||
if (!publisher) {
|
||||
publisher = createRedisInstance();
|
||||
}
|
||||
return publisher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a command to the Discord Gateway via Redis.
|
||||
* The DG's commandHandler listens on "backend:command" channel.
|
||||
@@ -58,7 +47,7 @@ function getPublisher(): Redis {
|
||||
export async function publishCommand(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const pub = getPublisher();
|
||||
const pub = getCommandPublisher();
|
||||
const envelope = {
|
||||
type: "command",
|
||||
data: payload,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("ws.server");
|
||||
|
||||
@@ -10,7 +10,6 @@ interface BroadcastEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Extend globalThis with broadcast function types
|
||||
declare global {
|
||||
var __broadcastFns:
|
||||
| {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bete/shared": "workspace:*",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@snazzah/davey": "^0.1.11",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import type { MediaState } from "../voice-recording/mediaTypes.js";
|
||||
import type {
|
||||
AnalysisQueueStatus,
|
||||
AttachmentRecord,
|
||||
MediaState,
|
||||
MessageRecord,
|
||||
ModerationWsEvent,
|
||||
} from "../message-capture/types.js";
|
||||
|
||||
@@ -171,33 +171,6 @@ export interface AnalysisResult {
|
||||
evidence?: string[];
|
||||
}
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
export type MediaSourceKind =
|
||||
| "url"
|
||||
| "local"
|
||||
| "youtube"
|
||||
| "spotify"
|
||||
| "search";
|
||||
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||
|
||||
export interface MediaQueueItem {
|
||||
id: string;
|
||||
mode: MediaMode;
|
||||
source: string;
|
||||
title: string;
|
||||
kind: MediaSourceKind;
|
||||
requestedBy: string;
|
||||
addedAt: number;
|
||||
status: MediaQueueItemStatus;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
export type ModerationWsEvent =
|
||||
| { type: "ui_state"; state: unknown }
|
||||
| { type: "user_state"; users: unknown[] }
|
||||
@@ -207,7 +180,7 @@ export type ModerationWsEvent =
|
||||
| { type: "message_analyzed"; data: MessageRecord }
|
||||
| { type: "attachment_created"; data: AttachmentRecord }
|
||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||
| { type: "media_state"; state: MediaState }
|
||||
| { type: "media_state"; state: unknown }
|
||||
| { type: "voice_recording_uploaded"; data: any };
|
||||
|
||||
export interface AnalysisQueueStatus {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
|
||||
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { AppError } from "../../shared/errors/errors.js";
|
||||
import { AppError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { discordPlayer } from "./player.js";
|
||||
import { startRecording, stopRecording } from "./recorder.js";
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
export { Guild, Channel } from "../../shared/api/client";
|
||||
|
||||
@@ -1,17 +1 @@
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string;
|
||||
source: string;
|
||||
title: string;
|
||||
mode?: MediaMode;
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
export { MediaMode, MediaItem, MediaState } from "../../shared/api/client";
|
||||
|
||||
@@ -14,6 +14,15 @@ export interface MessageMetadata {
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
|
||||
export function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
|
||||
@@ -1,14 +1 @@
|
||||
export type DashboardTab = "live" | "messages" | "analytics";
|
||||
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
selectedVoiceGuild?: string;
|
||||
selectedVoiceChannel?: string;
|
||||
selectedTextGuild?: string;
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: DashboardTab;
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
export { DashboardTab, UIState } from "../../shared/api/client";
|
||||
|
||||
@@ -1,14 +1 @@
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
activeChannelId?: string | null;
|
||||
activeChannelName?: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
export { VoiceStatus, ActiveSpeaker } from "../../shared/api/client";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Download, Mic } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
@@ -21,16 +22,6 @@ interface VoiceRecording {
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
|
||||
interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
import { parseMetadata } from "../../../entities/message/types";
|
||||
|
||||
interface ImageItem {
|
||||
url: string;
|
||||
@@ -13,15 +8,6 @@ interface ImageItem {
|
||||
message: MessageRecord;
|
||||
}
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
const images: ImageItem[] = [];
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Fragment, useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { moderateMessage } from "../../../shared/api/client";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { parseMetadata } from "../../../entities/message/types";
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
|
||||
@@ -71,21 +72,6 @@ interface MessageCardProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseStringList(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface ActiveSpeaker {
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string;
|
||||
source: string;
|
||||
|
||||
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user