feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config

Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
This commit is contained in:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
+5 -1
View File
@@ -17,9 +17,11 @@
"@bete/shared": "workspace:*",
"@discordjs/voice": "^0.19.2",
"axios": "^1.16.1",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.1.0",
"ioredis": "^5.11.0",
"pg": "^8.21.0",
@@ -30,12 +32,14 @@
},
"devDependencies": {
"@biomejs/biome": "latest",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^6.0.2",
"@types/node": "^25.9.0",
"@types/pg": "^8.20.0",
"@types/ws": "^8.18.1",
"tsx": "^4.22.2",
"typescript": "^5.9.3",
"@types/pg": "^8.20.0",
"vitest": "latest"
}
}
+9 -1
View File
@@ -4,7 +4,15 @@
*/
import { describe, it, expect } from "vitest";
const BASE = process.env.API_BASE ?? "https://imphnen.asepharyana.my.id/api";
// Safety: never default to a production URL — forces explicit opt-in
// via: API_BASE=http://localhost:3001/api vitest run
const RAW = process.env.API_BASE;
if (!RAW) {
throw new Error(
"API_BASE is not set. Run with: API_BASE=http://localhost:3001/api vitest run",
);
}
const BASE = RAW;
async function api(path: string, init?: RequestInit) {
const res = await fetch(`${BASE}${path}`, {
+115 -9
View File
@@ -1,3 +1,4 @@
import cors from "cors";
import { createChildLogger } from "@bete/shared/logger";
import express, {
type Express,
@@ -6,6 +7,8 @@ import express, {
type Response,
} from "express";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { createAdminRouter } from "../modules/admin/admin.routes.js";
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
import { createAuthRouter } from "../modules/auth/auth.routes.js";
import { createConfigRouter } from "../modules/config/config.routes.js";
@@ -19,14 +22,47 @@ import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import {
sessionAuth,
errorHandler,
} from "../shared/middlewares/index.js";
import { config } from "../shared/config/index.js";
import { isDashboardPublic } from "../shared/config/runtime.js";
const ADMIN_PASSWORD = config.ADMIN_PASSWORD || "admin";
const ADMIN_PASSWORD = config.ADMIN_PASSWORD;
const logger = createChildLogger("http.app");
// Whitelist of GET endpoints allowed in public (unauthenticated) mode.
// All other GET requests require auth even when DASHBOARD_IS_PUBLIC is true.
const PUBLIC_GET_PATHS = [
"/api/dashboard/stats",
"/api/dashboard/users",
"/api/dashboard/channels",
"/api/ui-state",
"/api/media/status",
"/api/mascot/chat/history",
"/api/messages",
"/api/analysis",
"/api/recordings",
"/api/voice",
];
/**
* Dynamic auth guard — checks runtime DASHBOARD_IS_PUBLIC setting for every request.
* In public mode: only whitelisted GET paths pass through; everything else requires auth.
* In private mode: all routes require auth.
*/
function protectedRoute(req: Request, res: Response, next: NextFunction) {
if (req.method === "GET" && isDashboardPublic()) {
const matched = PUBLIC_GET_PATHS.some(
(path) => req.path === path || req.path.startsWith(path + "/"),
);
if (matched) {
return next();
}
}
return sessionAuth(ADMIN_PASSWORD)(req, res, next);
}
export function createHttpApp(): Express {
const app = express();
@@ -37,10 +73,71 @@ export function createHttpApp(): Express {
}),
);
// CORS — allow known frontend origins
// Security note: strict origin whitelist prevents unauthorized cross-origin
// access. In production, ensure only legitimate frontend domains are listed.
// Development: local Vite preview ports
// Production: nginx reverse-proxy serves both on the same domain,
// but we whitelist them for browser preflights too.
const allowedOrigins = [
"http://localhost:5173", // Vite dev server
"http://localhost:4173", // Vite preview server
"http://localhost:3000", // Vite preview (alternate)
"http://localhost:3001", // Backend direct (dev)
"https://imphnen.asepharyana.my.id",
"https://imphnen.asepharyana.tech",
"https://imphnen.asepharyana.web.id",
];
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Admin-Password"],
maxAge: 86400, // 24 hours — browser can cache preflight
}),
);
// CSRF TODO: state-changing endpoints (POST, PATCH, DELETE) should
// implement CSRF protection (e.g., double-submit cookie pattern or
// SameSite=Strict + custom header check) before deploying to production.
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Global rate limiter — pertahanan lapisan pertama terhadap abuse
// Endpoint login (/api/auth/login) punya rate limiter sendiri yang lebih ketat
// TODO: The global limiter is currently applied only at /api/ prefix (line below),
// which leaves non-/api/ paths unguarded. Consider applying a lighter limiter
// to all paths or ensure nginx handles upstream rate limiting in production.
const globalLimiter = rateLimit({
windowMs: 15 * 1000, // 15 seconds
max: 200,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Skip health checks and WebSocket upgrade requests
if (req.path === "/api/health" || req.path === "/health") return true;
if (req.headers.upgrade === "websocket") return true;
return false;
},
message: {
error: "TOO_MANY_REQUESTS",
message: "Too many requests, please slow down",
},
});
app.use("/api/", globalLimiter);
// Request logging
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith("/api/")) {
@@ -63,26 +160,35 @@ export function createHttpApp(): Express {
next();
});
// Health check (no auth required)
// Open endpoints (no auth required)
app.use("/api", createHealthRouter());
// Auth (no auth required)
app.use("/api", createAuthRouter());
// Public read-only endpoints
app.use("/api", createConfigRouter());
// Admin endpoints — always require auth (manage settings, etc.)
// NOTE: createAdminRouter() sudah punya sessionAuth middleware internal,
// jadi tidak perlu middleware terpisah di sini.
app.use("/api", createAdminRouter());
// Protected routes — guarded by runtime DASHBOARD_IS_PUBLIC setting
// Public mode: GET is read-only, mutations require admin password
// Private mode: everything requires admin password
app.use("/api/dashboard", protectedRoute);
app.use("/api", createDashboardRouter());
// Protected routes — all routes are now public
app.use("/api", protectedRoute);
app.use("/api", createMessagesRouter());
app.use("/api", createAnalysisRouter());
app.use("/api", createMascotChatRouter());
// These routers are already guarded by the protectedRoute above
app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter());
app.use("/api", createRecordingsRouter());
app.use("/api", createUiStateRouter());
// Guilds routes
// Guilds routes — always protected (even in public mode)
app.use("/api/guilds", sessionAuth(ADMIN_PASSWORD));
app.use("/api/guilds", createGuildsRouter());
// 404 handler
@@ -0,0 +1,51 @@
import type { Request, Response, Router } from "express";
import express from "express";
import {
getRuntimeSettings,
updateRuntimeSettings,
} from "../../shared/config/runtime.js";
import { config } from "../../shared/config/index.js";
import { sessionAuth, asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("admin.routes");
export function createAdminRouter(): Router {
const router = express.Router();
// All admin routes require session-based auth
router.use(sessionAuth(config.ADMIN_PASSWORD));
// GET /api/admin/settings — read current runtime settings
router.get(
"/admin/settings",
asyncHandler(async (_req: Request, res: Response) => {
const settings = getRuntimeSettings();
res.json({
...settings,
envDashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
}),
);
// PATCH /api/admin/settings — update runtime settings (live, no restart)
router.patch(
"/admin/settings",
asyncHandler(async (req: Request, res: Response) => {
const { dashboardIsPublic } = req.body as {
dashboardIsPublic?: boolean;
};
const patch: Record<string, unknown> = {};
if (typeof dashboardIsPublic === "boolean") {
patch.dashboardIsPublic = dashboardIsPublic;
}
const updated = updateRuntimeSettings(patch);
logger.info({ ...patch }, "Runtime settings updated");
res.json(updated);
}),
);
return router;
}
@@ -1,32 +1,87 @@
import { timingSafeEqual } from "node:crypto";
import { UnauthorizedError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express";
import express from "express";
import rateLimit from "express-rate-limit";
import { config } from "../../shared/config/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
asyncHandler,
createSessionToken,
incrementTokenVersion,
sessionAuth,
} from "../../shared/middlewares/index.js";
const logger = createChildLogger("auth.routes");
const adminPassword = config.ADMIN_PASSWORD || "admin";
const adminPassword = config.ADMIN_PASSWORD;
// Rate limiter: max 10 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false, // Disable `X-RateLimit-*` headers
message: {
error: "TOO_MANY_REQUESTS",
message: "Too many login attempts, please try again later",
},
});
export function createAuthRouter(): Router {
const router = express.Router();
// POST /api/auth/login
// POST /api/auth/login — rate limited to prevent brute force
router.post(
"/auth/login",
loginLimiter,
asyncHandler(async (req: Request, res: Response) => {
const { password } = req.body as { password?: string };
logger.debug("Auth login attempt");
if (!password || password !== adminPassword) {
if (!password) {
throw new UnauthorizedError("Invalid password");
}
// Constant-time comparison prevents timing attacks
const pwBuf = Buffer.from(password);
const adminBuf = Buffer.from(adminPassword);
const maxLen = Math.max(pwBuf.length, adminBuf.length);
const diff =
pwBuf.length !== adminBuf.length ||
!timingSafeEqual(
Buffer.concat([pwBuf, Buffer.alloc(maxLen - pwBuf.length)]),
Buffer.concat([adminBuf, Buffer.alloc(maxLen - adminBuf.length)]),
);
if (diff) {
throw new UnauthorizedError("Invalid password");
}
const token = createSessionToken(adminPassword);
res.json({ ok: true, token });
}),
);
// POST /api/auth/logout — revoke all sessions for admin
router.post(
"/auth/logout",
sessionAuth(adminPassword),
asyncHandler(async (_req: Request, res: Response) => {
incrementTokenVersion("admin");
logger.info("Admin logged out — all sessions revoked");
res.json({ ok: true });
}),
);
// GET /api/auth/whoami — check if token is valid
router.get(
"/auth/whoami",
sessionAuth(adminPassword),
asyncHandler(async (_req: Request, res: Response) => {
res.json({ ok: true, sub: "admin" });
}),
);
return router;
}
@@ -1,28 +1,54 @@
import { UnauthorizedError } from "@bete/shared/errors";
import type { Router } from "express";
import express from "express";
import { config } from "../../shared/config/index.js";
import { isDashboardPublic } from "../../shared/config/runtime.js";
import { sessionAuth } from "../../shared/middlewares/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,
});
// GET /api/config — protected by runtime public/private mode
// Public mode: no auth needed (frontend needs config to determine auth state)
// Private mode: requires session-based auth
router.get("/config", (req, res, next) => {
if (isDashboardPublic()) {
// Public mode — return config without auth
return sendConfig(res);
} else {
// Private mode — require auth, then return config
sessionAuth(config.ADMIN_PASSWORD)(req, res, () => sendConfig(res));
}
});
return router;
}
function sendConfig(res: express.Response): void {
if (isDashboardPublic()) {
// Public mode — only expose safe, non-sensitive fields
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
return;
}
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,
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
}
@@ -8,7 +8,12 @@ export class DashboardRepository {
async getStats() {
const pool = getPool();
// Total messages and breakdown by ai_status
// Time-bounded aggregates — prevent full-table scan on large datasets
// Queries scope to last 90 days for performance, which covers the
// typical retention window anyway.
const BOUNDARY_DAYS = 90;
// Total messages and breakdown by ai_status (last 90 days)
const msgResult = await pool.query(
`
SELECT
@@ -24,32 +29,36 @@ export class DashboardRepository {
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
FROM messages
WHERE created_at >= $3
`,
[Date.now() - 86400000, Date.now() - 86400000],
[Date.now() - 86400000, Date.now() - 86400000, Date.now() - BOUNDARY_DAYS * 86400000],
);
const msgRow = msgResult.rows[0];
// Total voice recordings
const voiceResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM voice_recordings
`);
// Total voice recordings (last 90 days — bounded by retention window)
const voiceResult = await pool.query(
`SELECT COUNT(*)::int AS count FROM voice_recordings
WHERE created_at >= $1`,
[Date.now() - BOUNDARY_DAYS * 86400000],
);
// Total AI user profiles
const profileResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM user_profiles
`);
// Top channels by message count
// Top channels by message count (last 90 days)
const topChannels = await pool.query(`
SELECT channel_id,
(metadata::jsonb -> 'channel' ->> 'channelName') AS channel_name,
COUNT(*)::int AS message_count
FROM messages
WHERE created_at >= $1
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
ORDER BY COUNT(*) DESC
LIMIT 10
`);
`, [Date.now() - BOUNDARY_DAYS * 86400000]);
return {
total_messages: msgRow?.total_messages ?? 0,
@@ -23,7 +23,7 @@ export function createDashboardRouter(): Router {
router.get(
"/dashboard/users",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const cursor =
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const search =
@@ -52,16 +52,19 @@ export function createDashboardRouter(): Router {
router.get(
"/dashboard/channels",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const limit = Math.min(Number(req.query.limit) || 20, 100);
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 cursor =
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const result = await dashboardService.listChannels({
limit,
search,
guildId,
cursor,
});
res.json(result);
}),
@@ -29,6 +29,7 @@ export class DashboardService {
limit: number;
search?: string;
guildId?: string;
cursor?: string;
}) {
logger.debug({ query }, "Listing dashboard channels");
return dashboardRepository.listChannels(query);
@@ -1,6 +1,7 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response } from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { chatRequestSchema } from "./mascot-chat.schema.js";
import { mascotChatService } from "./mascot-chat.service.js";
const logger = createChildLogger("mascot-chat.controller");
@@ -11,15 +12,18 @@ interface AuthenticatedRequest extends Request {
export const handleMascotChat = asyncHandler(
async (req: Request, res: Response) => {
const { message, context } = req.body;
if (!message || typeof message !== "string") {
// Validate request body against schema
const parsed = chatRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "INVALID_INPUT",
message: "Message is required and must be a string",
message: "Invalid request body",
details: parsed.error.flatten().fieldErrors,
});
}
const { message, context } = parsed.data;
// Get user ID from auth middleware (if available)
const userId = (req as AuthenticatedRequest).userId || "anonymous";
@@ -6,6 +6,15 @@ import { uiStateService } from "./ui-state.service.js";
const logger = createChildLogger("ui-state.routes");
// Allowed UI state keys — reject any update that does not match these.
const ALLOWED_KEYS = new Set([
"activeTab",
"selectedVoiceGuild",
"selectedVoiceChannel",
"selectedTextChannel",
"sidebarCollapsed",
]);
export function createUiStateRouter(): Router {
const router = express.Router();
@@ -25,7 +34,14 @@ export function createUiStateRouter(): Router {
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);
// Filter to only allow known safe keys
const filtered: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
if (ALLOWED_KEYS.has(key)) {
filtered[key] = updates[key];
}
}
const result = await uiStateService.updateState(filtered);
res.json(result);
}),
);
@@ -0,0 +1,110 @@
/**
* Runtime configuration manager.
*
* Stores settings that can change at runtime (e.g., DASHBOARD_IS_PUBLIC)
* in a JSON file. Falls back to env-based defaults from the static config.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { createChildLogger } from "@bete/shared/logger";
import type { config } from "./index.js";
type Config = typeof config;
const logger = createChildLogger("runtime-config");
const DATA_DIR = resolve(import.meta.dirname ?? process.cwd(), "..", "data");
const SETTINGS_FILE = resolve(DATA_DIR, "settings.json");
interface RuntimeSettings {
dashboardIsPublic: boolean;
}
/** Nilai fallback dari env. Dipakai saat settings.json belum pernah dibuat. */
function envDefaultSettings(): RuntimeSettings {
return {
dashboardIsPublic: process.env.DASHBOARD_IS_PUBLIC === "true",
};
}
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
function loadSettings(): RuntimeSettings {
try {
ensureDataDir();
const fallback = envDefaultSettings();
if (!existsSync(SETTINGS_FILE)) {
writeFileSync(SETTINGS_FILE, JSON.stringify(fallback, null, 2));
return { ...fallback };
}
const raw = readFileSync(SETTINGS_FILE, "utf-8");
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
return { ...fallback, ...parsed };
} catch (err) {
logger.error({ err }, "Failed to load runtime settings");
return envDefaultSettings();
}
}
function saveSettings(settings: RuntimeSettings): void {
try {
ensureDataDir();
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
} catch (err) {
logger.error({ err }, "Failed to save runtime settings");
}
}
// ─── Singleton ───────────────────────────────────────────────────────────────
let _cache: RuntimeSettings | null = null;
function getSettings(): RuntimeSettings {
if (!_cache) {
_cache = loadSettings();
}
return _cache;
}
function invalidateCache(): void {
_cache = null;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Whether the dashboard is publicly accessible without auth, using runtime
* override if available, otherwise falling back to the env-based static config.
*/
export function isDashboardPublic(staticConfig?: Config): boolean {
const runtime = getSettings();
return runtime.dashboardIsPublic;
}
export function getRuntimeSettings(): RuntimeSettings {
return { ...getSettings() };
}
/**
* Update runtime settings. Pass only the fields you want to change.
* Invalidates the internal cache so the next read picks up changes.
*/
export function updateRuntimeSettings(
patch: Partial<RuntimeSettings>,
): RuntimeSettings {
const current = getSettings();
const updated = { ...current, ...patch };
saveSettings(updated);
invalidateCache();
return { ...updated };
}
/**
* Reset runtime settings to env-based defaults (does NOT change the file).
*/
export function resetRuntimeSettings(): void {
invalidateCache();
}
@@ -4,10 +4,150 @@ import {
ValidationError,
} from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
const logger = createChildLogger("middleware");
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
// ─── Revokable token version ──────────────────────────────────────────────
// Token version prevents compromised tokens from being valid indefinitely.
// Stored in Redis so version survives process restarts.
// Falls back to in-memory Map if Redis is unavailable.
// ──────────────────────────────────────────────────────────────────────────
const TOKEN_VERSION_REDIS_PREFIX = "token_version:";
const TOKEN_VERSION_TTL_S = 7 * 24 * 60 * 60; // 7 days — far longer than session lifetime
const tokenVersions = new Map<string, number>(); // in-memory fallback
async function tryLoadTokenVersion(sub: string): Promise<number | null> {
try {
const { readRedisStatus } = await import("../redis/index.js");
const raw = await readRedisStatus(`${TOKEN_VERSION_REDIS_PREFIX}${sub}`);
if (raw && typeof raw.version === "number") {
// Sync in-memory cache
tokenVersions.set(sub, raw.version);
return raw.version;
}
} catch {
// Redis unavailable — fall through to in-memory
}
return null;
}
async function tryPersistTokenVersion(sub: string, version: number): Promise<void> {
try {
const { getCommandPublisher } = await import("../redis/index.js");
const publisher = getCommandPublisher();
const key = `${TOKEN_VERSION_REDIS_PREFIX}${sub}`;
await publisher.set(key, JSON.stringify({ version }), "EX", TOKEN_VERSION_TTL_S);
} catch {
// Silently fall back to in-memory
}
}
export async function incrementTokenVersion(sub: string): Promise<number> {
const next = (tokenVersions.get(sub) ?? 0) + 1;
tokenVersions.set(sub, next);
// Fire-and-forget persist to Redis
tryPersistTokenVersion(sub, next).catch(() => {});
return next;
}
export async function getTokenVersion(sub: string): Promise<number> {
const cached = tokenVersions.get(sub);
if (cached !== undefined) return cached;
// Try loading from Redis
const remote = await tryLoadTokenVersion(sub);
if (remote !== null) return remote;
return 0;
}
// ─── JWT-like session token helpers ──────────────────────────────────────
// Simple HMAC-SHA256 token without external library dependency.
// Payload: { sub, iat, exp } base64url-encoded, signed with HMAC-SHA256.
interface SessionPayload {
sub: string; // e.g. "admin"
iat: number; // issued at (ms)
exp: number; // expires at (ms)
ver: number; // token version (revokable)
}
function base64urlEncode(data: string): string {
return Buffer.from(data)
.toString("base64url");
}
function base64urlDecode(str: string): string {
return Buffer.from(str, "base64url").toString("utf-8");
}
function signToken(payload: string, secret: string): string {
return createHmac("sha256", secret)
.update(payload)
.digest("base64url");
}
export function createSessionToken(adminPassword: string): string {
const now = Date.now();
// Note: getTokenVersion is async (Redis-backed). In practice, the version
// is cached in-memory after first load, so this is effectively sync.
// We use a sync fallback to keep the token-creation path non-async.
const ver = tokenVersions.get("admin") ?? 0;
const payload: SessionPayload = {
sub: "admin",
iat: now,
exp: now + SESSION_DURATION_MS,
ver,
};
const header = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64urlEncode(JSON.stringify(payload));
const signature = signToken(`${header}.${body}`, adminPassword);
return `${header}.${body}.${signature}`;
}
export function verifySessionToken(
token: string,
secret: string,
): SessionPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new UnauthorizedError("Invalid token format");
}
const [header, body, signature] = parts;
const expectedSig = signToken(`${header}.${body}`, secret);
try {
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSig);
if (
sigBuf.length !== expectedBuf.length ||
!timingSafeEqual(sigBuf, expectedBuf)
) {
throw new UnauthorizedError("Invalid token signature");
}
} catch {
throw new UnauthorizedError("Invalid token signature");
}
const payload = JSON.parse(base64urlDecode(body)) as SessionPayload;
if (Date.now() > payload.exp) {
throw new UnauthorizedError("Session token expired");
}
// Token version check — invalidate all tokens issued before version bump
// Note: getTokenVersion is async (Redis-backed). We fall back to the
// in-memory cache which is synced on first load from Redis. On startup
// the version defaults to 0, which is correct — no tokens revoked yet.
const currentVersion = tokenVersions.get(payload.sub) ?? 0;
if ((payload.ver ?? 0) < currentVersion) {
throw new UnauthorizedError("Session token has been revoked");
}
return payload;
}
// ─── Express middleware ──────────────────────────────────────────────────
export function errorHandler(
err: Error,
_req: Request,
@@ -30,15 +170,62 @@ export function errorHandler(
});
}
export function adminAuth(adminPassword: string) {
return (req: Request, res: Response, next: NextFunction) => {
const password = req.headers["x-admin-password"] as string;
/**
* @deprecated Replaced by sessionAuth(). Kept temporarily for transition
* period. TODO: remove after confirming no consumers remain.
*/
// export function adminAuth(adminPassword: string) {
// return (req: Request, res: Response, next: NextFunction) => {
// const password = req.headers["x-admin-password"] as string;
//
// if (!password || password !== adminPassword) {
// throw new UnauthorizedError("Invalid admin password");
// }
//
// next();
// };
// }
if (!password || password !== adminPassword) {
throw new UnauthorizedError("Invalid admin password");
/**
* Session-based auth middleware.
* Reads Bearer token from Authorization header and validates it.
* Falls back to X-Admin-Password header for backward compatibility.
*/
export function sessionAuth(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
// Try Authorization: Bearer <token> first
const authHeader = req.headers.authorization as string | undefined;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
try {
verifySessionToken(token, secret);
return next();
} catch (err) {
if (err instanceof AppError) {
throw err;
}
throw new UnauthorizedError("Invalid session token");
}
}
next();
// Fallback: X-Admin-Password header (for transition period)
const password = req.headers["x-admin-password"] as string;
if (password) {
try {
const pwBuf = Buffer.from(password);
const secretBuf = Buffer.from(secret);
if (
pwBuf.length === secretBuf.length &&
timingSafeEqual(pwBuf, secretBuf)
) {
return next();
}
} catch {
// Fall through to error below
}
}
throw new UnauthorizedError("Authentication required");
};
}
@@ -77,6 +77,7 @@ export async function publishCommand<T = unknown>(
const timer = setTimeout(() => {
if (settled) return;
settled = true;
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -90,6 +91,7 @@ export async function publishCommand<T = unknown>(
if (channel !== replyChannel || settled) return;
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -121,6 +123,7 @@ export async function publishCommand<T = unknown>(
if (!settled) {
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -133,6 +136,7 @@ export async function publishCommand<T = unknown>(
if (!settled) {
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
logger.error({ err }, "Failed to subscribe to reply channel");
resolve(null);
}
+83 -19
View File
@@ -66,9 +66,91 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
];
let subscriber: Redis | null = null;
let _redisHealthy = false;
function isRedisAvailable(): boolean {
return _redisHealthy;
}
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
function scheduleReconnect(): void {
if (_reconnectTimer) return; // already scheduled
_reconnectAttempts++;
if (_reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
logger.error("Redis subscriber max reconnect attempts reached");
_reconnectAttempts = 0;
return;
}
const delay = Math.min(1000 * Math.pow(2, _reconnectAttempts), 30_000);
logger.warn(
{ attempt: _reconnectAttempts, delayMs: delay },
"Redis subscriber reconnection scheduled",
);
_reconnectTimer = setTimeout(() => {
_reconnectTimer = null;
if (subscriber) {
subscriber
.connect()
.then(() => {
_redisHealthy = true;
_reconnectAttempts = 0;
logger.info("Redis subscriber reconnected");
// Re-subscribe after reconnect
const channels = SUBSCRIPTIONS.map((m) => m.channel);
return subscriber?.subscribe(...channels);
})
.catch((err: Error) => {
logger.error({ err }, "Redis subscriber reconnect failed");
scheduleReconnect();
});
}
}, delay);
}
function createSubscriber(): Redis {
return new Redis(config.REDIS_URL, { keyPrefix: "" });
const redis = new Redis(config.REDIS_URL, {
keyPrefix: "",
lazyConnect: true,
retryStrategy: (times) => {
// We handle reconnection ourselves
if (times > 3) return null;
return Math.min(times * 500, 2000);
},
});
redis.on("error", (err: Error) => {
const wasHealthy = _redisHealthy;
_redisHealthy = false;
if (wasHealthy) {
logger.warn({ err }, "Redis subscriber: connection lost");
} else {
logger.debug({ err }, "Redis subscriber error (not yet connected)");
}
});
redis.on("connect", () => {
_redisHealthy = true;
_reconnectAttempts = 0;
logger.info("Redis subscriber connected");
});
redis.on("close", () => {
_redisHealthy = false;
logger.warn("Redis subscriber connection closed");
// Schedule reconnection for lazy-connect mode
if (!_reconnectTimer) scheduleReconnect();
});
redis.on("reconnecting", () => {
logger.warn("Redis subscriber reconnecting…");
});
redis.on("message", handleSubscriptionMessage);
return redis;
}
function handleSubscriptionMessage(channel: string, message: string): void {
@@ -141,24 +223,6 @@ export async function startRedisBridge(): Promise<void> {
try {
subscriber = createSubscriber();
subscriber.on("error", (err: Error) => {
logger.error({ err }, "Redis subscriber error");
});
subscriber.on("connect", () => {
logger.info("Redis subscriber connected");
});
subscriber.on("reconnecting", () => {
logger.warn("Redis subscriber reconnecting…");
});
subscriber.on("close", () => {
logger.warn("Redis subscriber connection closed");
});
subscriber.on("message", handleSubscriptionMessage);
await subscriber.ping();
logger.info("Redis ping OK");
+194 -81
View File
@@ -7,6 +7,11 @@ import { setBroadcastFunctions } from "./broadcast.js";
const logger = createChildLogger("ws.server");
// Per-client sliding window rate limiter: max 30 messages per 5-second window
const RATE_LIMIT_WINDOW_MS = 5000;
const RATE_LIMIT_MAX_MSGS = 30;
const messageTimestamps = new WeakMap<WebSocket, number[]>();
interface BroadcastEvent {
type: string;
data: unknown;
@@ -71,18 +76,29 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const wss = new WebSocketServer({ server, path: "/ws" });
_wss = wss;
wss.on("connection", (ws: WebSocket, req) => {
// Parse auth token from query string
wss.on("connection", async (ws: WebSocket, req) => {
// Max connection limit — prevent resource exhaustion
const totalClients = frontendClients.size + gatewayClients.size;
const MAX_CONNECTIONS = 100;
if (totalClients >= MAX_CONNECTIONS) {
logger.warn({ totalClients }, "Max connections reached, rejecting new client");
ws.close(4003, "Server at capacity");
return;
}
// Gateway uses token in query string (internal-only connection, not in logs)
// Frontend uses auth message pattern to avoid token exposure in access logs
const rawUrl = req.url ?? "/";
let isGateway = false;
let queryToken: string | null = null;
try {
const url = new URL(rawUrl, "http://localhost");
const token = url.searchParams.get("token");
queryToken = url.searchParams.get("token");
isGateway =
token !== null &&
queryToken !== null &&
config.BACKEND_WS_TOKEN !== "" &&
token === config.BACKEND_WS_TOKEN;
queryToken === config.BACKEND_WS_TOKEN;
} catch {
// Malformed URL — treat as frontend
}
@@ -90,25 +106,120 @@ export function createWebSocketServer(server: Server): WebSocketServer {
if (isGateway) {
gatewayClients.add(ws);
logger.info("Discord gateway WebSocket client authenticated");
// Gateway doesn't need initial states
} else {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui, media) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
// Gateway only sends binary PCM — forward to frontend clients
ws.on("message", (data: Buffer) => {
if (Buffer.isBuffer(data)) {
broadcastBinaryToFrontend(data);
}
});
ws.on("close", () => {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
});
ws.on("error", (err: Error) => {
logger.error({ err }, "Gateway WebSocket error");
gatewayClients.delete(ws);
});
return;
}
ws.on("message", (data: Buffer) => {
// Gateway PCM forward — broadcast raw binary to frontend clients only
if (isGateway && Buffer.isBuffer(data)) {
broadcastBinaryToFrontend(data);
// ── Frontend client: auth message pattern ──────────────────────────
// Token is NEVER accepted in query string for frontend connections.
// Frontend must send { type: "auth", token: "..." } as first message.
// ────────────────────────────────────────────────────────────────────
// Origin check for frontend WebSocket connections
const origin = req.headers.origin;
if (origin) {
const allowedWsOrigins = [
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:3000",
"http://localhost:3001",
"https://imphnen.asepharyana.my.id",
"https://imphnen.asepharyana.tech",
"https://imphnen.asepharyana.web.id",
];
if (!allowedWsOrigins.includes(origin)) {
logger.warn({ origin }, "WebSocket connection rejected: origin not allowed");
ws.close(4002, "Origin not allowed");
return;
}
}
// Handle binary PCM from browser (FE→Discord transmit)
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
let authenticated = false;
let authTimer: ReturnType<typeof setTimeout> | null = null;
const { isDashboardPublic } = await import("../shared/config/runtime.js");
const isPublic = isDashboardPublic();
if (!isPublic) {
authTimer = setTimeout(() => {
if (!authenticated) {
ws.close(4001, "Authentication timeout");
logger.warn("Frontend WS connection timed out waiting for auth");
}
}, 5000);
} else {
authenticated = true;
frontendClients.add(ws);
}
function processFrontendMessage(data: Buffer): void {
// Validate auth before processing messages
if (!authenticated) {
try {
const msg = JSON.parse(data.toString());
if (
msg.type !== "auth" ||
typeof msg.token !== "string"
) {
return; // wait for valid auth
}
if (!isPublic) {
const { verifySessionToken } = require("../shared/middlewares/index.js");
verifySessionToken(msg.token, config.ADMIN_PASSWORD);
}
authenticated = true;
if (authTimer) {
clearTimeout(authTimer);
authTimer = null;
}
frontendClients.add(ws);
logger.info(`Frontend client authenticated (${frontendClients.size} total)`);
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
return;
} catch {
return; // invalid auth, wait for next message
}
}
// Per-client rate limiting — authenticated-only, max 30 msg / 5s sliding window
if (authenticated) {
const now = Date.now();
let timestamps = messageTimestamps.get(ws);
if (!timestamps) {
timestamps = [];
messageTimestamps.set(ws, timestamps);
}
// Prune timestamps outside the window
const cutoff = now - RATE_LIMIT_WINDOW_MS;
while (timestamps.length > 0 && timestamps[0]! < cutoff) {
timestamps.shift();
}
if (timestamps.length >= RATE_LIMIT_MAX_MSGS) {
logger.warn("Frontend client rate-limited (closing)");
ws.close(4006, "Rate limit exceeded");
return;
}
timestamps.push(now);
}
// Handle voice transmit binary
if (
Buffer.isBuffer(data) &&
data.length > 4 &&
@@ -148,78 +259,74 @@ export function createWebSocketServer(server: Server): WebSocketServer {
) {
try {
const message = JSON.parse(data.toString());
if (message.type === "voice_transmit" && message.buffer) {
// Legacy: Forward PCM data to Redis for discord-gateway
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: message.buffer,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
},
);
} else if (message.type === "voice_command" && message.command) {
// Forward voice commands to discord-gateway with payload
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
publisher
.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice command to Redis",
);
});
},
);
}
handleFrontendJsonMessage(message);
} catch (err) {
logger.debug({ err }, "Failed to parse WebSocket message as JSON");
}
}
});
}
ws.on("close", () => {
if (isGateway) {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
} else {
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
function handleFrontendJsonMessage(message: Record<string, unknown>): void {
if (message.type === "voice_transmit" && message.buffer) {
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: message.buffer,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
},
);
} else if (message.type === "voice_command" && message.command) {
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
publisher
.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice command to Redis",
);
});
},
);
}
}
ws.on("message", (data: Buffer) => processFrontendMessage(data));
ws.on("close", () => {
if (authTimer) clearTimeout(authTimer);
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
});
ws.on("error", (err: Error) => {
logger.error({ err }, "WebSocket client error");
if (isGateway) {
gatewayClients.delete(ws);
} else {
frontendClients.delete(ws);
}
logger.error({ err }, "Frontend WebSocket error");
if (authTimer) clearTimeout(authTimer);
frontendClients.delete(ws);
});
});
@@ -240,6 +347,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
function broadcastBinaryToFrontend(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(data);
} catch (err) {
@@ -260,6 +369,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
});
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(payload);
} catch (err) {
@@ -272,6 +383,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
function broadcastBinary(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(data);
} catch (err) {
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS "idx_messages_guild_ai_status_analyzed" ON "messages" USING btree ("guild_id","ai_status","ai_analyzed_at","id");--> statement-breakpoint
@@ -85,6 +85,13 @@
"when": 1781388000000,
"tag": "0011_add_voice_transcription",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781672400000,
"tag": "0012_light_prism",
"breakpoints": true
}
]
}
@@ -0,0 +1,86 @@
/**
* abortHelper.ts — Centralized AbortController with automatic timeout cleanup.
*
* All `new AbortController()` + `setTimeout(abort, ms)` patterns across the
* moderation subsystem are replaced by this module so that:
* 1. Every timer calls `.unref()` so it cannot keep Node alive during shutdown.
* 2. Cleanup is guaranteed via `cleanup()` or `withAbortTimeout()`.
* 3. The AbortError is distinguishable via `isAbortError()`.
*/
/**
* Create an AbortController that auto-aborts after `ms` milliseconds.
* Returns the signal and a `cleanup()` function that MUST be called
* (typically in a `finally` block) to cancel the timer.
*
* @example
* ```
* const { signal, cleanup } = createAbortTimeout(8000);
* try {
* await fetch(url, { signal });
* } finally {
* cleanup();
* }
* ```
*/
export function createAbortTimeout(ms: number): {
signal: AbortSignal;
cleanup: () => void;
} {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
// Prevent the timer from keeping the Node.js event loop alive during shutdown
timer.unref();
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
}
/**
* Higher-order wrapper that runs an async function with an abort timeout.
* The cleanup is handled automatically — callers never forget `clearTimeout`.
*
* If the operation is aborted by the timeout, the resulting error is re-thrown
* with a descriptive message.
*
* @example
* ```
* const result = await withAbortTimeout(8000, async (signal) => {
* return fetch(url, { signal }).then(r => r.json());
* }, "SearXNG search");
* ```
*/
export async function withAbortTimeout<T>(
ms: number,
fn: (signal: AbortSignal) => Promise<T>,
label = "operation",
): Promise<T> {
const { signal, cleanup } = createAbortTimeout(ms);
try {
return await fn(signal);
} catch (err) {
if (signal.aborted) {
throw new Error(`${label} timed out after ${ms}ms`);
}
throw err;
} finally {
cleanup();
}
}
/**
* Check whether an error was caused by an AbortController signal firing
* (either our explicit abort or the timeout).
*/
export function isAbortError(err: unknown): boolean {
if (err instanceof DOMException) return err.name === "AbortError";
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.message.includes("timed out after") ||
err.message.includes("aborted")
);
}
return false;
}
@@ -186,14 +186,14 @@ async function processBatch(job: {
}
}
const allRows: MessageRecord[] = [];
// ── Parallel: text-only + media analysis run concurrently ──────────
// Text-only → fast LLM call. Media → download + vision + LLM.
// Running both in parallel means media downloads overlap with text LLM call.
// Each path saves to DB as soon as its own results are ready.
// Each promise resolves to its own MessageRecord[] — combined via
// destructured Promise.all to avoid race conditions on a shared array.
// ────────────────────────────────────────────────────────────────────
const textPromise = textOnly.length > 0
const textPromise: Promise<MessageRecord[]> = textOnly.length > 0
? runModerationAnalysis({
targets: textOnly,
contextText: contextLines.join("\n"),
@@ -216,17 +216,18 @@ async function processBatch(job: {
}));
if (updates.length > 0) {
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
allRows.push(...rows);
logger.info(
{ count: updates.length, conversationKey },
"Text-only batch saved — media analysis still in progress",
);
return rows;
});
}
return [];
})
: Promise.resolve();
: Promise.resolve([]);
const mediaPromise = media.length > 0
const mediaPromise: Promise<MessageRecord[]> = media.length > 0
? runModerationAnalysis({
targets: media,
contextText: contextLines.join("\n"),
@@ -248,15 +249,15 @@ async function processBatch(job: {
},
}));
if (updates.length > 0) {
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
allRows.push(...rows);
});
return updateMessagesAIAnalysisBulk(updates);
}
return [];
})
: Promise.resolve();
: Promise.resolve([]);
// Wait for both to complete
await Promise.all([textPromise, mediaPromise]);
// Wait for both to complete and destructure results — no shared mutable array
const [textRows, mediaRows] = await Promise.all([textPromise, mediaPromise]);
const allRows = [...textRows, ...mediaRows];
logger.info(
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
@@ -178,22 +178,6 @@ export async function processBatch(
messages,
})) as AnalysisWorkerResponse;
// Do not broadcast or auto-delete if it's an API failure that will be reverted.
for (const row of result.rows) {
let isApiFailure = false;
if (row.ai_status === "error") {
try {
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
isApiFailure = flags.includes("analysis_api_failed");
} catch {}
}
if (!isApiFailure) {
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
}
// Post-batch reputation updates (fire-and-forget)
postBatchReputationUpdate(
result.rows.filter((r) => {
@@ -36,6 +36,10 @@ export const workerPool = new Piscina({
filename: fileURLToPath(getAnalysisWorkerUrl()),
execArgv: process.execArgv,
maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(),
// Each worker processes at most 1 task at a time so the pool itself
// acts as the concurrency governor. Combined with per-worker p-limit
// inside concurrencyLimiter.ts, this prevents LLM API overload.
concurrentTasksPerWorker: 1,
});
/**
@@ -228,7 +232,7 @@ export function scheduleAutoDelete(row: MessageRecord): void {
};
if (config.AUTO_DELETE_FLAGGED_DELAY_MS > 0) {
setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS);
setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS).unref();
return;
}
setImmediate(run);
@@ -22,7 +22,6 @@ function updateCounts(): void {
}
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
const queuedAt = activeCount + pendingCount;
pendingCount++;
logger.debug(
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
@@ -30,17 +29,17 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
);
return llmSemaphore(async () => {
pendingCount--;
activeCount++;
if (activeCount >= (config.AI_LLM_MAX_CONCURRENT ?? 5)) {
logger.warn(
{ activeCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
"LLM concurrency limit reached",
);
}
try {
pendingCount--;
activeCount++;
if (activeCount >= (config.AI_LLM_MAX_CONCURRENT ?? 5)) {
logger.warn(
{ activeCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
"LLM concurrency limit reached",
);
}
return await fn();
} finally {
activeCount--;
@@ -277,6 +277,50 @@ async function processIndividualFallback(
}
}
// ---------------------------------------------------------------------------
// Individual fallback retry queue (used when circuit breaker is active)
// ---------------------------------------------------------------------------
/** Messages awaiting retry when individual CB cools down. */
const individualRetryQueue = new LRUCache<string, MessageRecord>({ max: 10000 });
let individualRetryTimer: ReturnType<typeof setTimeout> | null = null;
const INDIVIDUAL_RETRY_CHECK_MS = 30000;
/**
* Schedule a retry for messages that were skipped because the individual
* circuit breaker was active. Retries once after cooldown expires.
*/
function scheduleIndividualRetry(messages: MessageRecord[]): void {
for (const msg of messages) {
if (!individualRetryQueue.has(msg.id)) {
individualRetryQueue.set(msg.id, msg);
}
}
if (individualRetryTimer === null) {
individualRetryTimer = setTimeout(() => {
individualRetryTimer = null;
if (Date.now() < individualCooldownUntil) {
// Still in cooldown — reschedule
scheduleIndividualRetry([]);
return;
}
const ids = [...individualRetryQueue.keys()];
const msgs: MessageRecord[] = [];
for (const id of ids) {
const m = individualRetryQueue.get(id);
if (m) {
individualRetryQueue.delete(id);
msgs.push(m);
}
}
if (msgs.length > 0) {
logger.info({ count: msgs.length }, "Retrying individual fallback messages after circuit breaker cooldown");
enqueueIndividualFallbacks(msgs);
}
}, INDIVIDUAL_RETRY_CHECK_MS);
}
}
// ---------------------------------------------------------------------------
// Enqueue individual fallbacks
// ---------------------------------------------------------------------------
@@ -295,8 +339,9 @@ export function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
until: new Date(individualCooldownUntil).toISOString(),
skipped: messages.length,
},
"Individual fallback circuit breaker active -- messages will be recovered later",
"Individual fallback circuit breaker active messages queued for retry",
);
scheduleIndividualRetry(messages);
return;
}
@@ -23,6 +23,7 @@ import type {
import { llmVision } from "./llmClient.js";
import { sanitizeAiContent } from "./moderationPrompt.js";
import {
buildAttachmentTextOnlyWarning,
buildCustomEmojiVisionPrompt,
buildGeneralImageVisionPrompt,
buildStickerTextOnlyWarning,
@@ -51,6 +52,7 @@ import { searchSearxng, extractSearchQueries, formatSearchResults } from "./sear
import { getUserProfile } from "./userProfileStore.js";
import { initializeUserReputation } from "./userReputationStore.js";
import { escapeXml, getAnalysisContent, buildReferenceXml } from "./moderationBuilders.js";
import { createAbortTimeout, isAbortError } from "./abortHelper.js";
// ---------------------------------------------------------------------------
// Types
@@ -292,50 +294,97 @@ async function downloadSingleAttachment(
targetId: string,
maxDimension: number,
imageMap: Map<string, MessageImagePart[]>,
mediaAnalysisMap?: Map<string, string[]>,
): Promise<void> {
const log = createChildLogger("mediaAnalysis");
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
if (!urlToUse) return;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(urlToUse, { signal: controller.signal });
if (!res.ok || !res.body) return;
// Collect all available URLs — uploaded_url first (Telegram CDN, faster),
// then discord_url as fallback (may have expired CDN signature)
const urlsToTry = [
att.uploaded_url,
att.discord_url,
].filter((url): url is string => url !== null && url !== undefined);
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) { reader.cancel(); return; }
chunks.push(value);
// No URL at all — record a neutral fallback so downstream knows the
// attachment existed but was unreachable
if (urlsToTry.length === 0) {
if (mediaAnalysisMap) {
const existing = mediaAnalysisMap.get(targetId) ?? [];
existing.push(`[attachment: "${att.filename}" dari pesan id=${targetId} — tidak ada URL untuk diunduh]`);
mediaAnalysisMap.set(targetId, existing);
}
return;
}
let lastError: Error | null = null;
// Try each URL, with 3 retries per URL (exponential backoff: 1s, 2s)
for (const url of urlsToTry) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { signal, cleanup } = createAbortTimeout(15000);
const res = await fetch(url, { signal });
if (!res.ok || !res.body) {
cleanup();
throw new Error(`HTTP ${res.status}`);
}
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) {
reader.cancel().catch(() => {});
res.body?.cancel().catch(() => {});
return;
}
chunks.push(value);
}
}
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime && att.type.startsWith("video/")) {
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap, signal);
return;
}
if (!sniffedMime) return;
const { data: resizedBuffer, mimeType: resizedMime } = await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
addImageToMap(imageMap, targetId, {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
});
cleanup();
return; // success!
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
log.warn(
{ attachmentId: att.id, url: url.slice(0, 80), attempt, error: lastError.message },
`Download attempt ${attempt + 1}/3 failed`,
);
if (attempt < 2) await delay(1000 * (attempt + 1)); // backoff: 1s, 2s
}
}
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime && att.type.startsWith("video/")) {
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap);
return;
}
if (!sniffedMime) return;
const { data: resizedBuffer, mimeType: resizedMime } = await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
addImageToMap(imageMap, targetId, {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
});
} catch (err) {
log.warn({ attachmentId: att.id, error: err instanceof Error ? err.message : String(err) }, "Download failed");
} finally {
clearTimeout(timeoutId);
}
// All URLs + all retries exhausted — record a neutral fallback
if (mediaAnalysisMap) {
const existing = mediaAnalysisMap.get(targetId) ?? [];
existing.push(buildAttachmentTextOnlyWarning(att.filename, targetId));
mediaAnalysisMap.set(targetId, existing);
}
log.warn(
{ attachmentId: att.id, filename: att.filename, error: lastError?.message },
"All download attempts exhausted for attachment",
);
}
async function extractVideoFrames(
@@ -344,6 +393,7 @@ async function extractVideoFrames(
targetId: string,
maxDimension: number,
imageMap: Map<string, MessageImagePart[]>,
signal?: AbortSignal,
): Promise<void> {
const log = createChildLogger("mediaAnalysis");
const execFileAsync = promisify(execFile);
@@ -351,15 +401,19 @@ async function extractVideoFrames(
const inputPath = path.join(tmpDir, att.filename || "video.mp4");
const outputPattern = path.join(tmpDir, "frame-%03d.jpg");
try {
if (signal?.aborted) return;
await writeFile(inputPath, videoBytes);
if (signal?.aborted) return;
const { stdout: durationStr } = await execFileAsync("/usr/bin/ffprobe", [
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath,
], { timeout: 10000 });
], { timeout: 10000, signal });
if (signal?.aborted) return;
const duration = parseFloat(durationStr.trim()) || 1;
const fps = (3 / duration).toFixed(6);
if (signal?.aborted) return;
await execFileAsync("/usr/bin/ffmpeg", [
"-i", inputPath, "-vf", `fps=${fps}`, "-frames:v", "4", "-vsync", "vfr", "-q:v", "2", outputPattern,
], { timeout: 30000 });
], { timeout: 30000, signal });
for (let i = 1; i <= 4; i++) {
try {
const framePath = path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`);
@@ -377,10 +431,8 @@ async function extractVideoFrames(
} catch (ffmpegErr) {
log.warn({ attachmentId: att.id, error: ffmpegErr instanceof Error ? ffmpegErr.message : String(ffmpegErr) }, "ffmpeg failed");
} finally {
try { await unlink(inputPath); } catch { /* ignore */ }
for (let i = 1; i <= 4; i++) {
try { await unlink(path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`)); } catch { /* ignore */ }
}
// rm(tmpDir, { recursive: true }) already removes all files inside,
// so individual unlink calls are redundant. Just clean up the whole dir.
try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
@@ -486,7 +538,7 @@ export async function prepareMediaMessage(
.filter((a) => a.message_id === targetId && (a.uploaded_url ?? a.discord_url ?? null) && (a.type.startsWith("image/") || a.type.startsWith("video/")))
.slice(0, 8);
for (const att of msgAttachments) {
downloadPromises.push(downloadSingleAttachment(att, targetId, maxDimension, imageMap));
downloadPromises.push(downloadSingleAttachment(att, targetId, maxDimension, imageMap, mediaAnalysisMap));
}
// URLs
@@ -505,8 +557,8 @@ export async function prepareMediaMessage(
await Promise.all(downloadPromises);
if (urlWebTexts.length > 0) webTextMap.set(targetId, urlWebTexts);
// Vision analysis
await Promise.all(
// Vision analysis — use allSettled so one failure doesn't cascade
const visionResults = await Promise.allSettled(
Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
images.map(async (image) => {
const summary = await analyzeSingleMediaImage(msgId, image);
@@ -516,6 +568,12 @@ export async function prepareMediaMessage(
}),
),
);
// Log any vision failures without aborting the batch
for (const r of visionResults) {
if (r.status === "rejected") {
log.warn({ error: r.reason instanceof Error ? r.reason.message : String(r.reason) }, "Individual vision analysis failed (batched)");
}
}
// SearXNG
let searxngXml = "";
@@ -29,6 +29,7 @@ import {
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import { getUserProfile } from "./userProfileStore.js";
import { initializeUserReputation } from "./userReputationStore.js";
import { createAbortTimeout, isAbortError } from "./abortHelper.js";
const log = createChildLogger("moderationOrchestrator");
@@ -40,10 +41,8 @@ interface RetryState {
lastInvalidContent: string | null;
}
// ---------------------------------------------------------------------------
// Few-shot correction builder
// ---------------------------------------------------------------------------
async function buildCorrectedFewShotExamples(): Promise<string> {
// ─── Few-shot correction builder ────────────────────────────────────────────
const _buildCorrectedFewShotExamples = async (): Promise<string> => {
try {
const corrections = await getRecentCorrectedModerations(5);
if (corrections.length === 0) return "";
@@ -62,6 +61,22 @@ async function buildCorrectedFewShotExamples(): Promise<string> {
} catch {
return "";
}
};
// ─── In-memory cache untuk correctedFewShotExamples ──────────────────────
// getCachedFewShotExamples() dipanggil di banyak tempat (setiap sub-batch
// dan retry), padahal datanya jarang berubah. Cache sederhana TTL 60 detik
// mengurangi redundant DB queries dari O(retries × subBatches) ke O(1).
let _fewShotCache: { result: string; expiresAt: number } | null = null;
const FEW_SHOT_CACHE_TTL = 60_000; // 60 detik
async function getCachedFewShotExamples(): Promise<string> {
if (_fewShotCache && Date.now() < _fewShotCache.expiresAt) {
return _fewShotCache.result;
}
const result = await _buildCorrectedFewShotExamples();
_fewShotCache = { result, expiresAt: Date.now() + FEW_SHOT_CACHE_TTL };
return result;
}
// ---------------------------------------------------------------------------
@@ -208,7 +223,25 @@ async function runTextOnlyBatch(
}
const urlArr = Array.from(allUrls).slice(0, 10);
if (urlArr.length === 0) return new Map<string, string>();
const results = await Promise.allSettled(urlArr.map((url) => fetchUrlSafely(url)));
// Per-host rate limiting: add a small delay between requests to the same
// domain to avoid overwhelming third-party servers with concurrent fetches.
const hostGroups = new Map<string, string[]>();
for (const url of urlArr) {
try {
const host = new URL(url).hostname;
const group = hostGroups.get(host) ?? [];
group.push(url);
hostGroups.set(host, group);
} catch { /* invalid URL, skip */ }
}
const results = await Promise.allSettled(
Array.from(hostGroups.values()).flatMap((group) =>
group.map((url, idx) => () =>
idx > 0 ? delay(200 * idx).then(() => fetchUrlSafely(url)) : fetchUrlSafely(url),
),
).map((fn) => fn()),
);
const map = new Map<string, string>();
for (let i = 0; i < urlArr.length; i++) {
const r = results[i];
@@ -291,7 +324,7 @@ async function runTextOnlyBatch(
const buildContent = async (state: RetryState): Promise<string> => {
const correction = state.lastParseError ? { error: state.lastParseError, preview: state.lastInvalidContent?.slice(0, 800) ?? "<empty>" } : undefined;
const correctedExamples = await buildCorrectedFewShotExamples();
const correctedExamples = await getCachedFewShotExamples();
const systemText = buildSystemPromptModular({ contextText, mode: "text", correction, correctedExamples, channelCulture });
const messagesBlock = (await Promise.all(batch.map(async (msg) => {
@@ -314,20 +347,18 @@ async function runTextOnlyBatch(
return `${systemText}${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
};
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
timeoutId.unref();
const { signal, cleanup } = createAbortTimeout(timeoutMs);
let batchResult: { results: AnalysisResult[]; raw: unknown };
try {
batchResult = await callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`, abortController.signal);
batchResult = await callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`, signal);
} catch (err: any) {
if (err.name === "AbortError" || abortController.signal.aborted) {
if (isAbortError(err)) {
throw new Error(`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
cleanup();
}
// Fan-out results for deduplicated messages
@@ -372,7 +403,7 @@ async function runMediaBatch(
const channelId = targets[0].channel_id;
const channelCultureObj = channelId ? await getChannelCulture(channelId) : null;
const channelCulture = channelCultureObj?.culture_summary;
const correctedExamples = await buildCorrectedFewShotExamples();
const correctedExamples = await getCachedFewShotExamples();
const systemText = buildSystemPromptModular({ contextText, mode: "mixed", correctedExamples, channelCulture });
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
@@ -381,26 +412,24 @@ async function runMediaBatch(
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
const batchTimeout = Math.min(Math.max(perMsgTimeout, perMsgTimeout * targets.length), 300_000);
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), batchTimeout);
timeoutId.unref();
const { signal, cleanup } = createAbortTimeout(batchTimeout);
try {
const result = await callModerationLLM(
async (_state: RetryState) => userContent,
targetIds,
`media-batch:${targetIds.length}msgs`,
abortController.signal,
signal,
);
log.info({ mediaCount: targets.length, resultCount: result.results.length }, "Media batch analysis complete");
return result;
} catch (err: any) {
if (err.name === "AbortError" || abortController.signal.aborted) {
if (isAbortError(err)) {
throw new Error(`Media batch analysis timed out after ${batchTimeout}ms for ${targets.length} messages`);
}
throw err;
} finally {
clearTimeout(timeoutId);
cleanup();
}
}
@@ -446,7 +475,7 @@ export async function runModerationAnalysis(
const rawContent = target.edited_content ?? target.content;
if (!rawContent.trim()) { uncachedTargets.push(target); continue; }
const cacheKey = makeTextModerationCacheKey(rawContent);
const cacheKey = makeTextModerationCacheKey(rawContent, target.user_id);
if (seenCacheKeys.has(cacheKey)) {
const previousHit = cacheHits.find((h) => h.messageId !== target.id);
if (previousHit) {
@@ -534,7 +563,7 @@ export async function runModerationAnalysis(
if (evidence.attachments.length > 0 || evidence.stickers.length > 0 || evidence.embeds.length > 0) continue;
}
const cacheKey = makeTextModerationCacheKey(rawContent);
const cacheKey = makeTextModerationCacheKey(rawContent, target.user_id);
setCachedTextModeration(cacheKey, {
flags: result.flags ?? [],
score: result.score ?? 0,
@@ -544,7 +573,7 @@ export async function runModerationAnalysis(
confidence: result.confidence ?? result.score ?? 0,
recommendedAction: result.recommendedAction ?? "none",
status: result.status,
}).catch(() => {});
}).catch((e) => log.error({ error: e instanceof Error ? e.message : String(e) }, "Failed to cache text moderation result"));
}
const allResults = [...cacheHits, ...textBatchResult.results, ...mediaBatchResult.results];
@@ -1,5 +1,6 @@
import Redis from "ioredis";
import { createChildLogger } from "@bete/shared/logger";
import { createAbortTimeout } from "./abortHelper.js";
const log = createChildLogger("searxng-search");
@@ -8,15 +9,97 @@ const MAX_RESULTS = 3;
const TIMEOUT_MS = 8000;
const CACHE_TTL = 86400; // 24 hours
const CACHE_PREFIX = "searxng:";
/** How often to attempt reconnection when Redis is down (ms). */
const RECONNECT_INTERVAL_MS = 60_000;
let redis: Redis | null = null;
let _initialized = false;
let _redisUrl = "";
/** Tracks whether Redis is currently healthy (connected + responding). */
let _redisHealthy = false;
let _lastLogAt = 0; // throttle repeated warn logs to once per 60s
// ---------------------------------------------------------------------------
// Health tracking
// ---------------------------------------------------------------------------
/**
* Returns true if the SearXNG Redis cache is connected and healthy.
* Use this for health-check endpoints or status dashboards.
*/
export function isSearxngCacheAvailable(): boolean {
return _redisHealthy;
}
/**
* Returns a human-readable status string for logging / health endpoints.
*/
export function getSearxngCacheStatus(): string {
if (!_initialized) return "not-initialized";
if (!redis) return "no-url";
return _redisHealthy ? "healthy" : "disconnected";
}
// ---------------------------------------------------------------------------
// Reconnection helper
// ---------------------------------------------------------------------------
/**
* Schedule a one-shot reconnection attempt after RECONNECT_INTERVAL_MS.
* Only one reconnection timer runs at a time.
*/
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleReconnect(): void {
if (_reconnectTimer) return; // already scheduled
_reconnectTimer = setTimeout(async () => {
_reconnectTimer = null;
if (!redis || _redisHealthy) return; // nothing to do
log.info("SearXNG Redis: attempting reconnection...");
try {
// ioredis reconnects automatically if `lazyConnect` is false,
// but we set it to true, so we need to manually call connect().
await redis.connect();
// If we get here, connection succeeded
_redisHealthy = true;
log.info("SearXNG Redis: reconnected successfully ✅");
} catch {
_redisHealthy = false;
const now = Date.now();
if (now - _lastLogAt > 60_000) {
log.warn(
{ nextRetryMs: RECONNECT_INTERVAL_MS },
"SearXNG Redis: reconnection failed — will retry",
);
_lastLogAt = now;
}
// Schedule another attempt
scheduleReconnect();
}
}, RECONNECT_INTERVAL_MS);
_reconnectTimer.unref?.();
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* Initialize Redis connection for SearXNG cache.
* Safe to call multiple times — only creates one connection.
* Returns true if Redis cache is available, false if falling back to no-cache.
*/
export function initSearxngCache(redisUrl: string): void {
if (redis) return;
export function initSearxngCache(redisUrl: string): boolean {
if (_initialized) return _redisHealthy;
_initialized = true;
_redisUrl = redisUrl;
if (!redisUrl) {
log.warn("No REDIS_URL provided — SearXNG cache disabled");
return false;
}
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
@@ -26,16 +109,92 @@ export function initSearxngCache(redisUrl: string): void {
lazyConnect: true,
enableReadyCheck: false,
});
redis.on("error", (err) => {
log.warn({ err: err.message }, "SearXNG Redis cache error");
const wasHealthy = _redisHealthy;
_redisHealthy = false;
if (wasHealthy) {
// State transition: healthy → unhealthy — always log
log.warn(
{ error: err.message },
"SearXNG Redis: connection lost — falling back to no-cache",
);
} else {
// Already unhealthy — throttle repeated error logs
const now = Date.now();
if (now - _lastLogAt > 60_000) {
log.warn(
{ error: err.message },
"SearXNG Redis: still disconnected",
);
_lastLogAt = now;
}
}
scheduleReconnect();
});
redis.on("ready", () => {
if (!_redisHealthy) {
_redisHealthy = true;
log.info("SearXNG Redis: connected and healthy ✅");
}
});
redis.connect().catch(() => {
log.warn("SearXNG Redis cache unavailable — falling back to no-cache");
redis = null;
_redisHealthy = false;
log.warn("SearXNG Redis: initial connection failed — running without cache");
scheduleReconnect();
});
log.info("SearXNG Redis cache initialized");
return true;
}
// ---------------------------------------------------------------------------
// Cache operations with visible failure logging
// ---------------------------------------------------------------------------
async function cacheGet(key: string): Promise<string | null> {
if (!redis || !_redisHealthy) return null;
try {
const result = await redis.get(key);
return result;
} catch (err) {
// Log once per minute to avoid log spam
const now = Date.now();
if (now - _lastLogAt > 60_000) {
log.warn(
{ error: err instanceof Error ? err.message : String(err) },
"SearXNG Redis: cache read failed",
);
_lastLogAt = now;
}
_redisHealthy = false;
scheduleReconnect();
return null;
}
}
function cacheSet(key: string, value: string, ttlSeconds: number): void {
if (!redis || !_redisHealthy) return;
redis.setex(key, ttlSeconds, value).catch((err) => {
const now = Date.now();
if (now - _lastLogAt > 60_000) {
log.warn(
{ error: err instanceof Error ? err.message : String(err) },
"SearXNG Redis: cache write failed",
);
_lastLogAt = now;
}
_redisHealthy = false;
scheduleReconnect();
});
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
export interface SearxngResult {
title: string;
url: string;
@@ -53,33 +212,30 @@ export async function searchSearxng(
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
// Try cache first
if (redis) {
const cached = await cacheGet(cacheKey);
if (cached) {
log.debug({ query, category }, "SearXNG cache HIT");
try {
const cached = await redis.get(cacheKey);
if (cached) {
log.debug({ query, category }, "SearXNG cache HIT");
return JSON.parse(cached) as SearxngResult[];
}
return JSON.parse(cached) as SearxngResult[];
} catch {
// Cache read failed, continue to API
// Corrupted cache entry — continue to API
}
}
// Cache miss — hit SearXNG API
try {
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const { signal, cleanup } = createAbortTimeout(TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
signal,
headers: {
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
clearTimeout(timeoutId);
cleanup();
if (!response.ok) {
log.warn({ status: response.status, query }, "SearXNG search failed");
@@ -97,11 +253,7 @@ export async function searchSearxng(
}));
// Store in cache (fire and forget — don't block on write)
if (redis) {
redis.setex(cacheKey, CACHE_TTL, JSON.stringify(mapped)).catch(() => {
// Cache write failed silently
});
}
cacheSet(cacheKey, JSON.stringify(mapped), CACHE_TTL);
log.debug({ query, category, resultCount: mapped.length }, "SearXNG search OK");
return mapped;
@@ -114,6 +266,10 @@ export async function searchSearxng(
}
}
// ---------------------------------------------------------------------------
// Query extraction
// ---------------------------------------------------------------------------
/**
* Extract meaningful search queries from message content.
* Uses multiple strategies to find terms worth searching.
@@ -179,6 +335,10 @@ export function extractSearchQueries(content: string): string[] {
return Array.from(queries).slice(0, 3);
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
/**
* Format SearXNG results as XML for LLM context.
*/
@@ -67,6 +67,24 @@ export function buildStickerTextOnlyWarning(
);
}
/**
* Build a neutral fallback text when an image/video attachment cannot be
* downloaded or analyzed. Unlike the sticker warning, this is deliberately
* neutral — it only records *that* an attachment existed, without any
* instruction to the LLM about how to treat it. The absence of visual
* data already means the LLM must rely on message content alone.
*
* Returns a formatted string for inclusion in the media context block.
*/
export function buildAttachmentTextOnlyWarning(
filename: string,
messageId: string,
): string {
const fallback = `[attachment: "${filename}" dari pesan id=${messageId} — tidak tersedia untuk analisis visual]`;
logger.debug({ filename, messageId }, "Built attachment text-only fallback");
return fallback;
}
/**
* Prompt used when a custom emoji image was successfully downloaded
* and is being sent to the vision LLM as a base64 image.
@@ -49,6 +49,11 @@ export async function getCachedText(
/**
* Insert or update a text analysis cache entry.
*
* NOTE: The INSERT ... ON CONFLICT pattern is intentional — for new cache
* keys we always INSERT rather than checking existence first, so there is
* no TOCTOU race. The ON CONFLICT DO UPDATE handles the case where another
* worker inserted the same key between our check and our insert.
*/
export async function upsertCachedText(
text: string,
@@ -291,12 +296,16 @@ export async function deleteCachedMediaAnalysis(
/**
* Generate a deterministic cache key for a per-user moderation result.
*
* Format: user_mod:<userId>:<sha256(content).slice(0,16)>
* Two users sending the same text get separate cache entries so that
* per-user action history (e.g. repeated spam) can be tracked later.
* Format: text_mod:<userId>:<sha256(content).slice(0,16)>
* By including userId, two users sending the same text get separate
* cache entries so per-user context (reputation, username flags, etc.)
* is respected.
*/
export function makeTextModerationCacheKey(content: string): string {
export function makeTextModerationCacheKey(content: string, userId?: string): string {
const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
if (userId) {
return `text_mod:${userId}:${hash}`;
}
return `text_mod:${hash}`;
}
@@ -1,6 +1,7 @@
import { resolve } from "node:dns/promises";
import { isIP } from "node:net";
import { createChildLogger } from "@bete/shared/logger";
import { createAbortTimeout } from "./abortHelper.js";
const log = createChildLogger("urlFetcher");
@@ -15,52 +16,103 @@ export interface FetchedUrlContext {
const MAX_FETCH_SIZE = 5 * 1024 * 1024; // 5 MB
const FETCH_TIMEOUT_MS = 8000;
const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/gi;
const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')?\]\s]/gi;
// ═══════════════════════════════════════════════════════════════════════════════
// SSRF Protection with DNS Rebinding Defense
// ═══════════════════════════════════════════════════════════════════════════════
// Strategy: Resolve the hostname to IP addresses BEFORE fetching, then fetch
// directly from a pinned IP (using a Host header for virtual hosting).
// This prevents DNS rebinding where a domain alternates between a public IP
// and an internal IP (127.0.0.1, 10.x.x.x) between the check and the fetch.
//
// Edge cases handled:
// - No DNS records → reject (cannot fetch)
// - Multiple IPs (round-robin DNS) → pick first public one
// - All IPs are internal → reject
// - Direct IP literal → validate and pass through
// ═══════════════════════════════════════════════════════════════════════════════
interface PinnedAddress {
/** The original hostname from the URL (used in Host header) */
hostname: string;
/** The pinned, validated IP address to connect to (already vetted as safe) */
ip: string;
/** The port from the original URL */
port: string;
/** The protocol (http: or https:) */
protocol: string;
/** The pathname + search + hash (everything after host:port) */
path: string;
}
function isPrivateIP(ip: string): boolean {
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "0.0.0.0" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
ip.startsWith("169.254.") || // link-local
ip.startsWith("fc") || // IPv6 unique local (fc00::/7)
ip.startsWith("fd") // IPv6 unique local
);
}
/**
* Basic SSRF protection.
* Note: A sophisticated attacker could still use DNS rebinding.
* Resolve a hostname to a pinned IP address.
* Returns the first public IP found, or null if all resolved IPs are private.
* Also returns null if the host is a private IP literal.
*
* This function is the sole gate once a safe IP is returned, the caller
* MUST use it directly without re-resolving the hostname.
*/
async function isSafeUrl(urlStr: string): Promise<boolean> {
async function resolveAndPinAddress(urlStr: string): Promise<PinnedAddress | null> {
try {
const parsed = new URL(urlStr);
const host = parsed.hostname;
const protocol = parsed.protocol; // "http:" or "https:"
const port = parsed.port || (protocol === "https:" ? "443" : "80");
const path = parsed.pathname + parsed.search + parsed.hash;
// Block obvious local IPs/hostnames
// Block private IP literals immediately
if (isIP(host)) {
if (isPrivateIP(host)) return null;
// Direct public IP literal — can fetch directly
return { hostname: host, ip: host, port, protocol, path };
}
// Block obvious private hostnames
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host.startsWith("192.168.") ||
host.startsWith("10.") ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host)
host === "localhost.localdomain" ||
host.endsWith(".local") ||
host.endsWith(".internal")
) {
return false;
return null;
}
// Try resolving to check if it resolves to a local IP
if (!isIP(host)) {
try {
const addresses = await resolve(host);
for (const ip of addresses) {
if (
ip === "127.0.0.1" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
) {
return false;
}
}
} catch (err) {
// If DNS fails, we can't fetch it anyway
return false;
}
// Resolve hostname to IP addresses
let addresses: string[];
try {
addresses = await resolve(host);
} catch {
// DNS resolution failed — can't verify safety
return null;
}
return true;
} catch (err) {
return false;
if (addresses.length === 0) return null;
// Pick the first non-private IP
const publicIp = addresses.find((ip) => !isPrivateIP(ip));
if (!publicIp) return null;
// We now have a pinned, verified safe IP.
// The caller MUST use this IP directly for the fetch.
return { hostname: host, ip: publicIp, port, protocol, path };
} catch {
return null;
}
}
@@ -108,20 +160,27 @@ export async function fetchUrlSafely(
return { url, type: "error", error: "Max redirect/meta depth reached" };
}
if (!(await isSafeUrl(url))) {
// Resolve + pin IP address FIRST (defence against DNS rebinding).
// The pinned IP is used directly — we never re-resolve the hostname.
const pinned = await resolveAndPinAddress(url);
if (!pinned) {
return { url, type: "error", error: "Unsafe URL blocked" };
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
// Reconstruct the URL using the pinned IP directly, keeping original Host
const pinnedUrl = `${pinned.protocol}//${pinned.ip}:${pinned.port}${pinned.path}`;
const { signal, cleanup } = createAbortTimeout(FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
const response = await fetch(pinnedUrl, {
signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 DiscordBot/2.0",
Accept: "image/webp,image/apng,image/*,*/*;q=0.8",
// Use original hostname so virtual hosting still works
Host: pinned.hostname,
},
// Do not follow more than a few redirects natively, fetch handles up to 20 by default
});
@@ -190,7 +249,7 @@ export async function fetchUrlSafely(
error: err instanceof Error ? err.message : String(err),
};
} finally {
clearTimeout(timeoutId);
cleanup();
}
}
@@ -63,11 +63,10 @@ async function learnUserProfile(
.join("\n");
const prompt = `Anda adalah AI ahli psikologi, analisis perilaku online, dan pembaca karakter.
Tugas Anda adalah merangkum profil kepribadian SEORANG PRIBADI bukan sekadar statistik
gaya bicara berdasarkan riwayat pesan-pesan mereka di server Discord.
Tugas Anda adalah merangkum profil kepribadian SEORANG PRIBADI berdasarkan riwayat pesan-pesan mereka di server Discord.
Buatlah ringkasan yang KAYA AKAN PERSONALITAS sehingga pembaca merasa "mengenal" orang ini.
Pesan-pesan terakhir dari user "${userId}" (hanya pesan bersih/clean):
Pesan-pesan terakhir dari user (hanya pesan bersih/clean):
<messages>
${messagesText}
</messages>
@@ -122,7 +121,18 @@ atau konten SARA, itu akan SANGAT tidak sesuai dengan karakternya dan patut dicu
const text = completion.choices[0]?.message?.content?.trim();
if (!text) throw new Error("Empty response from LLM");
await updateUserProfile(userId, guildId, text);
// Sanitize the AI-generated profile before saving to prevent
// prompt injection when the profile is later injected into prompts.
// Strip markdown code fences and XML special chars.
const sanitized = text
.replace(/```[\s\S]*?```/g, "")
.replace(/[<>&"']/g, (ch) => {
const entities: Record<string, string> = { "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" };
return entities[ch] || ch;
})
.trim();
await updateUserProfile(userId, guildId, sanitized);
log.info(
{ userId, guildId },
"Successfully learned and updated user profile",
@@ -12,19 +12,25 @@ let rawPool: Pool | null = null;
/**
* Initialize the PostgreSQL database connection.
* When called from a Piscina worker thread, pool min/max are reduced to
* avoid exhausting PG connections across many worker processes.
*/
export async function initializeDatabase() {
if (db !== null) {
return db;
}
const isWorker = typeof process.env.PISCINA_WORKER !== "undefined";
const poolMin = isWorker ? 1 : config.POSTGRES_POOL_MIN;
const poolMax = isWorker ? 2 : config.POSTGRES_POOL_MAX;
let pool: Pool;
if (config.DATABASE_URL) {
pool = new Pool({
connectionString: config.DATABASE_URL,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
min: poolMin,
max: poolMax,
});
} else {
pool = new Pool({
@@ -33,8 +39,8 @@ export async function initializeDatabase() {
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
min: poolMin,
max: poolMax,
});
}
+155
View File
@@ -0,0 +1,155 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = never;
}
+18
View File
@@ -0,0 +1,18 @@
{
"pid": 866059,
"port": 3000,
"url": "http://localhost:3000",
"urls": {
"local": [
"http://localhost:3000/"
],
"network": [
"http://192.168.1.65:3000/",
"http://100.114.19.66:3000/",
"http://172.27.0.1:3000/",
"http://172.26.0.1:3000/"
]
},
"background": false,
"startedAt": "2026-07-01T15:35:45.072Z"
}
+5
View File
@@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1782920146192
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="astro/client" />
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
// ─────────────────────────────────────────────────────────────
// BETE Astro Configuration
// Tailwind v4 ditangani via PostCSS (postcss.config.js)
// ─────────────────────────────────────────────────────────────
export default defineConfig({
integrations: [react()],
output: "static",
// Dev server
server: {
host: "0.0.0.0",
port: 3000,
},
// Preview
preview: {
host: true,
port: 3000,
allowedHosts: [
"imphnen.asepharyana.my.id",
"imphnen.asepharyana.tech",
"imphnen.asepharyana.web.id",
],
},
// Vite config
vite: {
server: {
allowedHosts: [
"imphnen.asepharyana.my.id",
"imphnen.asepharyana.tech",
"imphnen.asepharyana.web.id",
],
watch: {
// Penting: Astro punya public/ dir sendiri, jangan bentrok
ignored: ["!**/node_modules/**"],
},
},
// PostCSS otomatis terdeteksi dari root project
},
});
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#23a1eb" />
<title>IMPHNEN — Discord Moderation</title>
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6 -6
View File
@@ -4,18 +4,20 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build",
"preview": "vite preview --host 0.0.0.0 --port 3000",
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0 --port 3000",
"typecheck": "tsc --noEmit",
"lint": "biome check --diagnostic-level=error src/",
"format": "biome format --write src/"
},
"dependencies": {
"@astrojs/react": "^6.0.0",
"@bete/shared": "workspace:*",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"astro": "^7.0.4",
"clsx": "^2.1.1",
"framer-motion": "^12.4.0",
"lucide-react": "^1.16.0",
@@ -28,11 +30,9 @@
"@tailwindcss/postcss": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"vite": "^8.0.13"
"typescript": "^5.9.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
// ─── App.client.tsx — Astro React island entry point ────────────────────────
// DILOAD OLEH Astro client:only="react"
// Menyediakan <div id="root"> dan mount App dengan provider yang diperlukan
// ─────────────────────────────────────────────────────────────────────────────
import React from "react";
import App from "./App";
import { ToastProvider } from "./shared/ui";
export default function AppClient() {
return (
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>
);
}
+324 -88
View File
@@ -1,5 +1,14 @@
import { useEffect, useMemo, useState } from "react";
// ─── App.tsx — The God Component ──────────────────────────────────────────────
// TODO: Decompose into smaller focused components (M20).
// This component currently handles auth, socket lifecycle, speaker tracking,
// voice control, media, PTT, command palette, and tab navigation.
// Each concern should be extracted into its own hook or sub-component.
// ───────────────────────────────────────────────────────────────────────────────
import { AnimatePresence } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ActiveSpeaker } from "./entities/voice/types.js";
import { AuthOverlay } from "./features/auth";
import { DashboardPanel } from "./features/dashboard";
import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl";
@@ -10,26 +19,118 @@ import {
mergeMessages,
useMessages,
} from "./features/messages/hooks/useMessages";
import { getAppConfig } from "./shared/api/client";
import { SettingsPanel } from "./features/settings";
import { useNotificationBadge } from "./hooks/useNotificationBadge";
import { useTheme } from "./hooks/useTheme";
import {
getAppConfig,
getSessionToken,
getAdminPassword,
clearSessionToken,
clearAdminPassword,
} from "./shared/api/client";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useUIState } from "./shared/hooks/useUIState";
import { CommandPalette } from "./shared/ui/CommandPalette";
import { ErrorBoundary } from "./shared/ui/error-boundary";
import { MobileTabBar } from "./shared/ui/MobileTabBar";
import type { DashboardTab } from "./entities/ui/types.js";
import { useDashboardSocket } from "./shared/ws/socket";
import { DashboardLayout } from "./widgets/DashboardLayout";
type AuthState = "loading" | "authenticated" | "unauthenticated";
export default function App() {
const { uiState, patchUIState } = useUIState();
const { theme, mode, isDark, toggle: toggleTheme, setMode } = useTheme();
const voice = useVoiceControl();
const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<
(ActiveSpeaker & { heardAt?: number })[]
>([]);
(ActiveSpeaker & { heardAt?: number })[]>([]);
const [monitorGuildId, setMonitorGuildId] = useState("");
// ── Command palette state ────────────────────────────────────────────────
const [paletteOpen, setPaletteOpen] = useState(false);
const [paletteMode, setPaletteMode] = useState<"search" | "shortcuts" | null>(null);
// ── Auth state ─────────────────────────────────────────────────────────────
const [authState, setAuthState] = useState<AuthState>("loading");
const [dashboardIsPublic, setDashboardIsPublic] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const configRetryRef = useRef(0);
const configTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const MAX_CONFIG_RETRIES = 3;
// ── Notification badge ─────────────────────────────────────────────────────
const activeTab: DashboardTab = (uiState.activeTab as DashboardTab) || "messages";
const notifBadge = useNotificationBadge(activeTab);
// On mount: check config for public/private mode, and check stored session token
useEffect(() => {
// Clear legacy admin-password from localStorage — only use token auth now
clearAdminPassword();
// Validate existing token by calling config endpoint
// If server returns 401, clear the invalid token
const attempt = () => {
getAppConfig()
.then((cfg) => {
configRetryRef.current = 0;
setConfigError(null);
setMonitorGuildId(cfg.monitorGuildId ?? "");
setDashboardIsPublic(cfg.dashboardIsPublic);
// Check if we have a session token (new auth) or legacy password (backward compat)
const sessionToken = getSessionToken();
const storedPassword = getAdminPassword();
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
setAuthState("authenticated");
} else {
setAuthState("unauthenticated");
}
})
.catch((err) => {
// If server responds with 401, token is invalid — clear it
if (err?.statusCode === 401 || err?.status === 401) {
clearSessionToken();
setAuthState("unauthenticated");
return;
}
configRetryRef.current += 1;
const isNetwork =
err instanceof TypeError &&
(err.message === "Failed to fetch" ||
err.message.includes("NetworkError") ||
err.message.includes("network"));
if (isNetwork && configRetryRef.current < MAX_CONFIG_RETRIES) {
// Retry with backoff: 1s, 2s, 3s
const delay = configRetryRef.current * 1000;
configTimeoutRef.current = setTimeout(attempt, delay);
} else {
// Final failure — show auth overlay with retry button
setConfigError(
isNetwork
? "Cannot reach server. Check your connection and try again."
: "Failed to load configuration.",
);
setAuthState("unauthenticated");
}
});
};
attempt();
return () => {
if (configTimeoutRef.current) {
clearTimeout(configTimeoutRef.current);
configTimeoutRef.current = null;
}
};
}, []);
const audio = useAudioPlayback();
const activeTab = uiState.activeTab || "messages";
const selectedVoiceGuild =
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
@@ -77,7 +178,7 @@ export default function App() {
),
onVoiceActiveUser: (data) => {
if (data.userId) audio.registerUserId(data.userId);
setActiveSpeakers((prev) =>
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) =>
updateSpeakerList(prev, {
userId: data.userId,
username: data.username,
@@ -86,12 +187,25 @@ export default function App() {
}),
);
},
onVoiceRecordingStarted: () =>
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
onVoiceRecordingStopped: () =>
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
onVoiceRecordingStarted: (data) =>
window.dispatchEvent(
new CustomEvent("voice_recording_started", { detail: data }),
),
onVoiceRecordingStopped: (data) =>
window.dispatchEvent(
new CustomEvent("voice_recording_stopped", { detail: data }),
),
onVoiceAnalyzed: (data) =>
window.dispatchEvent(
new CustomEvent("voice_analyzed", { detail: data }),
),
onMessageCreated: (m) =>
messages.setMessages((prev) => mergeMessages(prev, [m])),
messages.setMessages((prev) => {
// Skip if message already exists with same status (dedup)
const existing = prev.find((i) => i.id === m.id);
if (existing && existing.ai_status === m.ai_status) return prev;
return mergeMessages(prev, [m]);
}),
onMessageUpdated: (m) =>
messages.setMessages((prev) =>
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
@@ -103,7 +217,12 @@ export default function App() {
),
),
onMessageAnalyzed: (msg) => {
messages.setMessages((prev) => mergeMessages(prev, [msg]));
messages.setMessages((prev) => {
// Skip if message already analyzed with same status (dedup)
const existing = prev.find((i) => i.id === msg.id);
if (existing && existing.ai_status === msg.ai_status) return prev;
return mergeMessages(prev, [msg]);
});
const status = msg.ai_status;
if (status === "flagged") {
const username = msg.username || msg.user_id || "unknown";
@@ -134,17 +253,6 @@ export default function App() {
const transmit = useAudioTransmit(socket.socketRef);
// Load app config on mount
useEffect(() => {
getAppConfig()
.then((c) => {
if (c.monitorGuildId) {
setMonitorGuildId(c.monitorGuildId);
}
})
.catch(() => undefined);
}, []);
// Load voice channels when guild changes (Live tab)
useEffect(() => {
if (selectedVoiceGuild)
@@ -158,18 +266,24 @@ export default function App() {
}, [monitorGuildId, messages.fetchMessages]);
// Periodic refetch — keeps dashboard in sync even if WS events missed
const monitorGuildRef = useRef(monitorGuildId);
monitorGuildRef.current = monitorGuildId;
useEffect(() => {
if (!monitorGuildId) return;
const currentGuild = monitorGuildRef.current;
if (!currentGuild) return;
const interval = setInterval(() => {
messages.fetchMessages(monitorGuildId).catch(() => undefined);
messages.fetchMessages(monitorGuildRef.current).catch(() => undefined);
}, 15_000);
return () => clearInterval(interval);
}, [monitorGuildId, messages.fetchMessages]);
return () => {
clearInterval(interval);
};
}, [monitorGuildId]);
// Stale speaker pruning — remove speakers not heard from in 30s
useEffect(() => {
const interval = setInterval(() => {
setActiveSpeakers((prev) => {
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => {
const now = Date.now();
const pruned = prev.filter(
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
@@ -207,70 +321,192 @@ export default function App() {
};
}, [transmit]);
// ── Command palette keyboard shortcut handler ──────────────────────────────
const handlePaletteOpen = useCallback((mode: "search" | "shortcuts") => {
setPaletteMode(mode);
setPaletteOpen(true);
}, []);
const handlePaletteClose = useCallback(() => {
setPaletteOpen(false);
setPaletteMode(null);
}, []);
const handlePaletteNavigate = useCallback(
(tab: string) => {
patchUIState({ activeTab: tab as DashboardTab });
handlePaletteClose();
},
[patchUIState, handlePaletteClose],
);
// ── Tab navigation handler ─────────────────────────────────────────────────
const handleTabChange = useCallback(
(tab: DashboardTab) => {
patchUIState({ activeTab: tab });
},
[patchUIState],
);
// ── Render main content based on active tab ────────────────────────────────
const renderContent = () => {
switch (activeTab) {
case "live":
return (
<ErrorBoundary message="Live panel crashed">
<LivePanel
guilds={voice.guilds}
voiceChannels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={uiState.selectedVoiceChannel || ""}
micLevel={0}
status={voice.voiceStatus}
voiceLoading={voice.loading}
activeSpeakers={activeSpeakers}
levels={audio.levels}
isListening={audio.isListening}
isStreaming={transmit.isStreaming}
mediaState={media.mediaState}
mediaLoading={media.loading}
onGuildChange={(id) =>
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
}
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
onJoin={() =>
voice.joinVoice(
selectedVoiceGuild,
uiState.selectedVoiceChannel || "",
)
}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={audio.toggleListening}
onStreamingToggle={transmit.toggle}
onQueueMusic={(s) => media.enqueue(s, "music")}
onStartScreen={(s) => media.enqueue(s, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
</ErrorBoundary>
);
case "dashboard":
return (
<ErrorBoundary message="Dashboard panel crashed">
<DashboardPanel />
</ErrorBoundary>
);
case "settings":
return (
<ErrorBoundary message="Settings panel crashed">
<SettingsPanel
themeMode={mode}
isDark={isDark}
onThemeModeChange={setMode}
/>
</ErrorBoundary>
);
default:
return (
<ErrorBoundary message="Messages panel crashed">
<MessagesPanel
guildName={monitorGuildName}
messages={messages.messages}
onReanalyze={messages.reanalyze}
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
onLoadMore={messages.loadMore}
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
</ErrorBoundary>
);
}
};
// ── Render: Auth loading ─────────────────────────────────────────────────
if (authState === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">
{configError
? "Connection lost — retrying..."
: `Connecting to server${".".repeat(configRetryRef.current)}`}
</p>
</div>
</div>
);
}
// ── Render: Auth overlay ─────────────────────────────────────────────────
if (authState === "unauthenticated") {
return (
<AuthOverlay
isPublic={dashboardIsPublic}
onAuthenticated={() => setAuthState("authenticated")}
configError={configError}
onRetryConfig={() => {
setConfigError(null);
setAuthState("loading");
configRetryRef.current = 0;
// Re-trigger the config fetch by forcing remount via key trick
// Actually: just re-run attempt logic
getAppConfig()
.then((cfg) => {
setMonitorGuildId(cfg.monitorGuildId ?? "");
setDashboardIsPublic(cfg.dashboardIsPublic);
const sessionToken = getSessionToken();
const storedPassword = getAdminPassword();
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
setAuthState("authenticated");
} else {
setAuthState("unauthenticated");
}
})
.catch(() => {
setConfigError("Server still unreachable. Try again later.");
setAuthState("unauthenticated");
});
}}
/>
);
}
// ── Render: Main app (authenticated) ─────────────────────────────────────
return (
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
recentMessages={messages.messages}
guildId={monitorGuildId}
channelId={
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
}
>
{activeTab === "live" ? (
<LivePanel
guilds={voice.guilds}
voiceChannels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={uiState.selectedVoiceChannel || ""}
micLevel={0}
status={voice.voiceStatus}
voiceLoading={voice.loading}
activeSpeakers={activeSpeakers}
levels={audio.levels}
isListening={audio.isListening}
isStreaming={transmit.isStreaming}
mediaState={media.mediaState}
mediaLoading={media.loading}
onGuildChange={(id) =>
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
}
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
onJoin={() =>
voice.joinVoice(
selectedVoiceGuild,
uiState.selectedVoiceChannel || "",
)
}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={audio.toggleListening}
onStreamingToggle={transmit.toggle}
onQueueMusic={(s) => media.enqueue(s, "music")}
onStartScreen={(s) => media.enqueue(s, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
) : activeTab === "dashboard" ? (
<DashboardPanel />
) : (
<MessagesPanel
guildName={monitorGuildName}
messages={messages.messages}
onReanalyze={messages.reanalyze}
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
onLoadMore={messages.loadMore}
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
)}
<>
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
themeMode={mode}
isDark={isDark}
onTabChange={handleTabChange}
onThemeToggle={toggleTheme}
recentMessages={messages.messages}
guildId={monitorGuildId}
channelId={
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
}
notificationCount={notifBadge.count}
>
<AnimatePresence mode="wait">
{renderContent()}
</AnimatePresence>
</DashboardLayout>
<MobileTabBar
activeTab={activeTab}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
onTabChange={handleTabChange}
/>
<ModerationAlertListener />
</DashboardLayout>
<CommandPalette
isOpen={paletteOpen}
mode={paletteMode}
onClose={handlePaletteClose}
onNavigate={handlePaletteNavigate}
onToggleTheme={toggleTheme}
isDark={isDark}
/>
</>
);
}
+9 -2
View File
@@ -6,13 +6,20 @@ export interface UIState {
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: "live" | "messages" | "dashboard";
activeTab?: DashboardTab;
isListening?: boolean;
isStreaming?: boolean;
}
export type DashboardTab = "live" | "messages" | "dashboard";
export type DashboardTab = "live" | "messages" | "dashboard" | "settings";
export interface AppConfig {
monitorGuildId: string | null;
dashboardIsPublic: boolean;
}
/** Response from GET /api/admin/settings */
export interface AdminSettings {
dashboardIsPublic: boolean;
envDashboardIsPublic: boolean;
}
@@ -0,0 +1,295 @@
import { motion } from "framer-motion";
import {
Eye,
EyeOff,
Globe,
Lock,
RefreshCw,
Save,
Settings,
Shield,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { AdminSettings } from "../../shared/api/client";
import {
getAdminSettings,
updateAdminSettings,
clearSessionToken,
logout,
} from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../shared/ui";
export function AdminPanel() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleLogout = async () => {
// Call server-side logout to increment token version
try {
await logout();
} catch {
// Even if server call fails, still clear local state for security
}
// Clear local token and legacy password
clearSessionToken();
localStorage.removeItem("admin-password");
window.location.reload();
};
const fetchSettings = async () => {
setLoading(true);
setError(null);
try {
const data = await getAdminSettings();
setSettings(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleTogglePublic = async () => {
if (!settings) return;
const newValue = !settings.dashboardIsPublic;
setSaving(true);
setError(null);
setSuccess(null);
try {
const updated = await updateAdminSettings({
dashboardIsPublic: newValue,
});
setSettings(updated);
setSuccess(
newValue
? "Dashboard is now public — accessible without password."
: "Dashboard is now private — admin password required.",
);
setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update settings");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription>Loading settings...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
</CardContent>
</Card>
);
}
if (error && !settings) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription className="text-destructive">{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={fetchSettings} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" /> Retry
</Button>
</CardContent>
</Card>
);
}
const isPublic = settings?.dashboardIsPublic ?? false;
return (
<motion.div variants={cardStagger} initial="initial" animate="animate">
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-primary">
<Settings className="h-5 w-5" />
Admin Settings
</CardTitle>
<CardDescription>
Manage dashboard visibility and runtime configuration.
</CardDescription>
</div>
<Button
onClick={fetchSettings}
variant="ghost"
size="sm"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Success / Error messages ── */}
{success && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
{success}
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* ── Dashboard Visibility ── */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
{isPublic ? (
<Globe className="h-4 w-4 text-emerald-500" />
) : (
<Lock className="h-4 w-4 text-amber-500" />
)}
<h3 className="font-semibold">
Dashboard Visibility:{" "}
<span
className={
isPublic ? "text-emerald-500" : "text-amber-500"
}
>
{isPublic ? "Public" : "Private"}
</span>
</h3>
</div>
<p className="text-sm text-muted-foreground">
{isPublic
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
: "Admin password is required to access any part of the dashboard."}
</p>
</div>
<Button
onClick={handleTogglePublic}
disabled={saving}
variant={isPublic ? "outline" : "default"}
size="sm"
className="shrink-0"
>
{saving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Saving...
</>
) : isPublic ? (
<>
<Lock className="mr-2 h-4 w-4" />
Make Private
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Make Public
</>
)}
</Button>
</div>
{/* ── Status indicators ── */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Runtime</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
isPublic ? "bg-emerald-400" : "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{isPublic ? "Public" : "Private"}
</span>
</div>
</div>
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">
Env Default
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
settings?.envDashboardIsPublic
? "bg-emerald-400"
: "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{settings?.envDashboardIsPublic ? "Public" : "Private"}
</span>
</div>
</div>
</div>
</div>
{/* ── Logout ── */}
<div className="flex justify-end">
<Button
onClick={handleLogout}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<Lock className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
{/* ── Info card ── */}
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
<div className="flex items-start gap-3">
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-1 text-xs text-muted-foreground">
<p>
<strong>Admin password</strong> is configured via the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
ADMIN_PASSWORD
</code>
environment variable. For security, it cannot be changed
through this panel update it in your deployment
configuration and restart the service.
</p>
<p className="mt-2">
Runtime settings are persisted across restarts in the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
data/settings.json
</code>
file. Changes take effect immediately, no restart needed.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
+96 -15
View File
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Lock } from "lucide-react";
import { useState } from "react";
import { login } from "../../shared/api/client.js";
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
import { useState, useCallback } from "react";
import { login, setSessionToken } from "../../shared/api/client.js";
import {
Button,
Card,
@@ -14,67 +14,148 @@ import {
interface AuthOverlayProps {
onAuthenticated: () => void;
isPublic: boolean;
configError?: string | null;
onRetryConfig?: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
export function AuthOverlay({
onAuthenticated,
isPublic,
configError,
onRetryConfig,
}: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isNetworkError, setIsNetworkError] = useState(false);
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
setLoading(true);
setError(null);
setIsNetworkError(false);
try {
await login(password);
localStorage.setItem("admin-password", password);
const result = await login(password);
// Store session token (new auth method)
if (result.token) {
setSessionToken(result.token);
}
// Clean up legacy stored password from localStorage if it was there
// from a previous session (before JWT migration)
localStorage.removeItem("admin-password");
onAuthenticated();
} catch {
setError("Invalid password");
} catch (err) {
const isNetwork =
err instanceof TypeError &&
(err.message === "Failed to fetch" ||
err.message.includes("NetworkError") ||
err.message.includes("network"));
setIsNetworkError(isNetwork);
setError(
isNetwork
? "Cannot reach server — check your connection or try again."
: "Invalid password",
);
} finally {
setLoading(false);
}
};
// ── Retry config fetch (initial loading state) ──────────────────────────────
const [retryCount, setRetryCount] = useState(0);
const handleRetry = useCallback(() => {
setRetryCount((r) => r + 1);
}, []);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex items-center justify-center p-4"
className="flex min-h-screen items-center justify-center p-4"
>
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex items-center justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
{isPublic ? (
<Shield className="h-6 w-6" />
) : (
<Lock className="h-6 w-6" />
)}
</div>
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardTitle>
{isPublic ? "Admin Authentication" : "Admin Access Required"}
</CardTitle>
<CardDescription>
Enter the admin password to access Voice and Media controls.
{isPublic
? "Enter the admin password to manage settings and perform administrative actions."
: "Enter the admin password to access the dashboard."}
</CardDescription>
</CardHeader>
<CardContent>
{configError && (
<div className="mb-4 flex flex-col items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-center">
<WifiOff className="h-6 w-6 text-amber-500" />
<p className="text-xs text-amber-600">{configError}</p>
{onRetryConfig && (
<Button
onClick={onRetryConfig}
variant="outline"
size="sm"
className="gap-2 border-amber-500/30 text-amber-600 hover:bg-amber-500/10"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry Connection
</Button>
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
placeholder="Enter admin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && (
<div
className={`flex items-start gap-2 rounded-lg p-2 text-xs ${
isNetworkError
? "bg-amber-500/10 text-amber-600"
: "text-destructive"
}`}
>
{isNetworkError ? (
<WifiOff className="mt-0.5 h-3.5 w-3.5 shrink-0" />
) : (
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
)}
<span>{error}</span>
</div>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={loading || !password}
>
{loading ? "Authenticating..." : "Unlock Controls"}
{loading ? "Authenticating..." : "Unlock"}
</Button>
</form>
{isPublic && (
<p className="mt-4 text-xs text-center text-muted-foreground">
<Unlock className="inline h-3 w-3 mr-1" />
The dashboard is in public mode most data is visible without
authentication. Admin password is only needed for management actions.
</p>
)}
</CardContent>
</Card>
</motion.div>
@@ -1,4 +1,6 @@
import { Settings } from "lucide-react";
import { useState } from "react";
import { AdminPanel } from "../../features/admin/AdminPanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
import { ChannelSummaryList } from "./components/ChannelSummaryList";
@@ -87,6 +89,10 @@ export function DashboardPanel() {
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="channels">Channels</TabsTrigger>
<TabsTrigger value="admin" className="flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5" />
Admin
</TabsTrigger>
</TabsList>
<TabsContent value="stats">
@@ -120,6 +126,10 @@ export function DashboardPanel() {
onSelectChannel={setSelectedChannelId}
/>
</TabsContent>
<TabsContent value="admin">
<AdminPanel />
</TabsContent>
</Tabs>
);
}
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
return (
<div className="space-y-3">
{recordings.map((rec) => (
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
<div key={rec.id} className="rounded-xl border border-border bg-card">
<div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" />
@@ -82,7 +82,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
href={image.url}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
className="group overflow-hidden rounded-xl border border-primary/20 bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
>
<div className="relative aspect-video overflow-hidden">
{image.kind === "sticker" ? (
@@ -42,6 +42,10 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode {
loading="lazy"
draggable={false}
title={`:${name}:`}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>,
);
lastIndex = regex.lastIndex;
@@ -80,13 +84,13 @@ function parseStringList(value?: string | null): string[] {
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-100 text-red-700 border-red-200";
return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800";
case "high":
return "bg-orange-100 text-orange-700 border-orange-200";
return "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800";
case "medium":
return "bg-yellow-100 text-yellow-700 border-yellow-200";
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
case "low":
return "bg-blue-100 text-blue-700 border-blue-200";
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
@@ -333,6 +337,10 @@ function MessageRow({
alt={sticker.name || "sticker"}
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
loading="lazy"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
@@ -360,6 +368,10 @@ function MessageRow({
alt={img.name}
className="h-16 w-16 object-cover transition-transform hover:scale-105"
loading="lazy"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
</a>
))}
@@ -380,7 +392,7 @@ function MessageRow({
key={vid.url}
src={vid.url}
controls
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
preload="metadata"
/>
))}
@@ -409,8 +421,8 @@ function MessageRow({
<div
className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged"
? "border-l-pink-400 bg-pink-50/40"
: "border-l-emerald-400 bg-emerald-50/40"
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
}`}
>
<div className="flex items-start gap-2 text-[11px]">
@@ -431,7 +443,7 @@ function MessageRow({
{/* AI Error */}
{message.ai_error ? (
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
<div className="rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
AI error: {message.ai_error}
</div>
) : null}
@@ -451,7 +463,7 @@ function MessageRow({
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-[11px] text-pink-600/70">
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
Click to retry analysis
</span>
)}
@@ -483,7 +495,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
return (
<article
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
firstMsg.deleted_at ? "border-red-200 dark:border-red-900/50 opacity-60" : "border-border"
}`}
>
<div className="flex gap-3 p-4">
@@ -495,6 +507,10 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
onError={(e) => {
const target = e.currentTarget;
target.src = "https://cdn.discordapp.com/embed/avatars/0.png";
}}
/>
<div className="min-w-0 flex-1">
@@ -0,0 +1,426 @@
import { motion } from "framer-motion";
import {
Bell,
BellOff,
Moon,
Palette,
Sun,
Monitor,
Settings,
Shield,
Globe,
Lock,
Volume2,
VolumeX,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "../../hooks/useTheme";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
import {
getAdminSettings,
updateAdminSettings,
clearSessionToken,
} from "../../shared/api/client";
import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
/* ─── Storage keys ─────────────────────────────────────────────────────── */
const NOTIF_ENABLED_KEY = "bete-notif-enabled";
const NOTIF_SOUND_KEY = "bete-notif-sound";
/* ─── Types ────────────────────────────────────────────────────────────── */
interface NotificationPrefs {
enabled: boolean;
sound: boolean;
}
function loadNotifPrefs(): NotificationPrefs {
try {
const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
return {
enabled: raw !== "false", // default true
sound: soundRaw !== "false", // default true
};
} catch {
return { enabled: true, sound: true };
}
}
/* ─── Props ────────────────────────────────────────────────────────────── */
interface SettingsPanelProps {
themeMode: ThemeMode;
isDark: boolean;
onThemeModeChange: (mode: ThemeMode) => void;
}
/* ─── Component ────────────────────────────────────────────────────────── */
export function SettingsPanel({
themeMode,
isDark,
onThemeModeChange,
}: SettingsPanelProps) {
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs>(loadNotifPrefs);
const [adminSettings, setAdminSettings] = useState<AdminSettingsType | null>(null);
const [adminSaving, setAdminSaving] = useState(false);
const [adminError, setAdminError] = useState<string | null>(null);
const [adminSuccess, setAdminSuccess] = useState<string | null>(null);
const adminSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load admin settings on mount
useEffect(() => {
getAdminSettings()
.then(setAdminSettings)
.catch(() => {
// Not authenticated — ignore
});
}, []);
const handleTogglePublic = async () => {
if (!adminSettings) return;
const newValue = !adminSettings.dashboardIsPublic;
setAdminSaving(true);
setAdminError(null);
setAdminSuccess(null);
// Clear any existing auto-clear timer
if (adminSuccessTimerRef.current) {
clearTimeout(adminSuccessTimerRef.current);
}
try {
const updated = await updateAdminSettings({ dashboardIsPublic: newValue });
setAdminSettings(updated);
setAdminSuccess(
newValue
? "Dashboard is now public — accessible without password."
: "Dashboard is now private — admin password required.",
);
// Auto-clear success message after 4s
adminSuccessTimerRef.current = setTimeout(() => setAdminSuccess(null), 4000);
} catch (err) {
setAdminError(err instanceof Error ? err.message : "Failed to update");
} finally {
setAdminSaving(false);
}
};
const handleLogout = () => {
clearSessionToken();
window.location.reload();
};
const updateNotif = useCallback(
(patch: Partial<NotificationPrefs>) => {
setNotifPrefs((prev) => {
const next = { ...prev, ...patch };
try {
localStorage.setItem(NOTIF_ENABLED_KEY, String(next.enabled));
localStorage.setItem(NOTIF_SOUND_KEY, String(next.sound));
} catch {
/* quota */
}
// Dispatch event so other components can react
window.dispatchEvent(
new CustomEvent("notif_prefs_changed", { detail: next }),
);
return next;
});
},
[],
);
const themeOptions: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
desc: string;
}> = [
{
value: "light",
label: "Light",
icon: Sun,
desc: "Always use light theme",
},
{
value: "dark",
label: "Dark",
icon: Moon,
desc: "Always use dark theme",
},
{
value: "system",
label: "System",
icon: Monitor,
desc: "Follow system preference",
},
];
return (
<motion.div
className="mx-auto max-w-2xl space-y-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
{/* ── Theme section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Palette className="h-5 w-5" />
Theme
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{themeOptions.map((opt) => {
const Icon = opt.icon;
const isActive = themeMode === opt.value;
return (
<button
key={opt.value}
onClick={() => onThemeModeChange(opt.value)}
className={`
flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all
${
isActive
? "border-primary bg-primary/5 text-primary"
: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
}
`}
>
<Icon
className={`h-6 w-6 ${
opt.value === "dark" && !isActive
? "text-indigo-400"
: opt.value === "light" && !isActive
? "text-amber-500"
: ""
}`}
/>
<span className="text-sm font-semibold">{opt.label}</span>
<span className="text-xs">{opt.desc}</span>
{isActive && (
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary" />
)}
</button>
);
})}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Current: <span className="font-medium text-foreground capitalize">{isDark ? "Dark" : "Light"}</span>
{themeMode === "system" && " (follows system)"}
</p>
</CardContent>
</Card>
</motion.div>
{/* ── Notifications section ────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Toggle — enable/disable all notifs */}
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3">
{notifPrefs.enabled ? (
<Bell className="h-5 w-5 text-primary" />
) : (
<BellOff className="h-5 w-5 text-muted-foreground" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Moderation alerts
</p>
<p className="text-xs text-muted-foreground">
Show toast when a message is flagged by AI
</p>
</div>
</div>
<button
role="switch"
aria-checked={notifPrefs.enabled}
onClick={() => updateNotif({ enabled: !notifPrefs.enabled })}
className={`
relative h-6 w-11 rounded-full transition-colors
${notifPrefs.enabled ? "bg-primary" : "bg-muted"}
`}
>
<span
className={`
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
${notifPrefs.enabled ? "translate-x-5" : "translate-x-0"}
`}
/>
</button>
</label>
{/* Toggle — sound */}
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3">
{notifPrefs.sound ? (
<Volume2 className="h-5 w-5 text-primary" />
) : (
<VolumeX className="h-5 w-5 text-muted-foreground" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Sound effects
</p>
<p className="text-xs text-muted-foreground">
Play a sound when new moderation alerts arrive
</p>
</div>
</div>
<button
role="switch"
aria-checked={notifPrefs.sound}
onClick={() => updateNotif({ sound: !notifPrefs.sound })}
className={`
relative h-6 w-11 rounded-full transition-colors
${notifPrefs.sound ? "bg-primary" : "bg-muted"}
`}
>
<span
className={`
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
${notifPrefs.sound ? "translate-x-5" : "translate-x-0"}
`}
/>
</button>
</label>
</CardContent>
</Card>
</motion.div>
{/* ── Admin section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Settings className="h-5 w-5" />
Admin Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Dashboard visibility toggle */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
{adminSettings?.dashboardIsPublic ? (
<Globe className="mt-0.5 h-5 w-5 text-emerald-500 dark:text-emerald-400 shrink-0" />
) : (
<Lock className="mt-0.5 h-5 w-5 text-amber-500 dark:text-amber-400 shrink-0" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Dashboard Visibility:{" "}
<span className={adminSettings?.dashboardIsPublic ? "text-emerald-500 dark:text-emerald-400" : "text-amber-500 dark:text-amber-400"}>
{adminSettings?.dashboardIsPublic ? "Public" : "Private"}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{adminSettings?.dashboardIsPublic
? "Anyone can view the dashboard. Admin password still required for management."
: "Admin password required to access the dashboard."}
</p>
</div>
</div>
<Button
onClick={handleTogglePublic}
disabled={adminSaving}
variant={adminSettings?.dashboardIsPublic ? "outline" : "default"}
size="sm"
className="shrink-0"
>
{adminSaving ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : adminSettings?.dashboardIsPublic ? (
"Make Private"
) : (
"Make Public"
)}
</Button>
</div>
{adminError && (
<p className="mt-2 text-xs text-destructive">{adminError}</p>
)}
{adminSuccess && (
<p className="mt-2 text-xs text-emerald-500 dark:text-emerald-400">{adminSuccess}</p>
)}
</div>
{/* Status indicators */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Runtime</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.dashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
<span className="text-sm font-medium">{adminSettings?.dashboardIsPublic ? "Public" : "Private"}</span>
</div>
</div>
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Env Default</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.envDashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
<span className="text-sm font-medium">{adminSettings?.envDashboardIsPublic ? "Public" : "Private"}</span>
</div>
</div>
</div>
{/* Logout */}
<div className="flex justify-end border-t border-border pt-4">
<Button
onClick={handleLogout}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<Lock className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
{/* Info */}
<div className="rounded-lg bg-muted/30 px-3 py-2">
<div className="flex items-start gap-2">
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Admin password is set via the <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">ADMIN_PASSWORD</code> env var.
Runtime settings are persisted in <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">data/settings.json</code>.
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* ── About section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-muted-foreground">About</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Bete Dashboard v1.0 Discord AI Moderation & Voice Recording
System.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Theme settings are saved locally. Notification preferences are
persisted across sessions.
</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Tracks incoming moderation_alert events and maintains a badge counter.
* Clears when the user navigates to the messages tab.
*/
export function useNotificationBadge(activeTab: string) {
const [count, setCount] = useState(0);
const prevActiveTab = useRef(activeTab);
// Clear badge when user switches TO messages tab
useEffect(() => {
if (activeTab === "messages" && prevActiveTab.current !== "messages") {
setCount(0);
}
prevActiveTab.current = activeTab;
}, [activeTab]);
const increment = useCallback(() => {
setCount((c) => c + 1);
}, []);
// Listen for moderation_alert custom events
useEffect(() => {
const handler = () => increment();
window.addEventListener("moderation_alert", handler);
return () => window.removeEventListener("moderation_alert", handler);
}, [increment]);
return { count, clear: () => setCount(0) };
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useMemo, useState } from "react";
export type Theme = "light" | "dark";
export type ThemeMode = Theme | "system";
const THEME_STORAGE_KEY = "bete-dashboard-theme";
function getSystemTheme(): Theme {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function loadThemeMode(): ThemeMode {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system")
return stored;
} catch {
// ignore
}
return "system";
}
function resolveTheme(mode: ThemeMode): Theme {
return mode === "system" ? getSystemTheme() : mode;
}
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.setAttribute("data-theme", theme);
// Also toggle Tailwind dark class for utility-based approach
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}
export function useTheme() {
const [mode, setModeState] = useState<ThemeMode>(loadThemeMode);
const theme = useMemo(() => resolveTheme(mode), [mode]);
const setMode = useCallback((newMode: ThemeMode) => {
setModeState(newMode);
try {
localStorage.setItem(THEME_STORAGE_KEY, newMode);
} catch {
// ignore quota errors
}
}, []);
const toggle = useCallback(() => {
setMode(theme === "dark" ? "light" : "dark");
}, [theme, setMode]);
// Apply theme on mount and when mode changes
useEffect(() => {
applyTheme(theme);
}, [theme]);
// Listen for system preference changes when in "system" mode
useEffect(() => {
if (mode !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
applyTheme(resolveTheme("system"));
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [mode]);
return { theme, mode, setMode, toggle, isDark: theme === "dark" };
}
@@ -0,0 +1,84 @@
---
// ─── BaseLayout.astro — BETE's eternal shell ─────────────────────────────────
// Handles: anti-FOUC theme, font loading, global CSS, meta tags.
// All interactive content is delegated to React islands via <slot/>.
// ──────────────────────────────────────────────────────────────────────────────
// Font dari design system: Outfit (menggantikan Poppins)
const FONT_HREF =
"https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap";
const FALLBACK_TITLE = "IMPHNEN — Discord Moderation";
const FALLBACK_DESC =
"Real-time Discord AI Moderation & Voice Recording Dashboard";
const FALLBACK_IMAGE =
"https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg";
---
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" id="meta-theme-color" content="#ffffff" />
<!-- Primary Meta Tags -->
<title>{FALLBACK_TITLE}</title>
<meta name="title" content={FALLBACK_TITLE} />
<meta name="description" content={FALLBACK_DESC} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content={FALLBACK_TITLE} />
<meta property="og:description" content={FALLBACK_DESC} />
<meta property="og:image" content={FALLBACK_IMAGE} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={FALLBACK_TITLE} />
<meta property="twitter:description" content={FALLBACK_DESC} />
<meta property="twitter:image" content={FALLBACK_IMAGE} />
<!-- Icon -->
<link rel="icon" type="image/svg+xml" href={FALLBACK_IMAGE} />
<!-- Font Preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href={FONT_HREF} rel="stylesheet" />
<!-- ── Anti-FOUC: apply saved theme BEFORE React renders ────────────── -->
<script is:inline>
(function () {
try {
var stored = localStorage.getItem("bete-dashboard-theme");
var theme = "light";
if (stored === "dark") {
theme = "dark";
} else if (stored === "system") {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
var root = document.documentElement;
root.setAttribute("data-theme", theme);
if (theme === "dark") root.classList.add("dark");
// Sync meta theme-color
var meta = document.getElementById("meta-theme-color");
if (meta) {
meta.setAttribute(
"content",
theme === "dark" ? "#0f0f12" : "#ffffff",
);
}
} catch (e) {
// localStorage unavailable — safe to ignore
}
})();
</script>
</head>
<body>
<!-- React island: seluruh app di-render oleh React -->
<slot />
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ToastProvider } from "./shared/ui";
import "./styles.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>,
);
+12
View File
@@ -0,0 +1,12 @@
---
// ─── index.astro — BETE's main entry point ──────────────────────────────────
// Shell statis: semua interaktivitas di-delegate ke React island
// ──────────────────────────────────────────────────────────────────────────────
import BaseLayout from "../layouts/BaseLayout.astro";
import AppClient from "../App.client";
---
<BaseLayout>
<!-- React island: seluruh SPA di-render oleh React -->
<AppClient client:only="react" />
</BaseLayout>
+100 -14
View File
@@ -24,6 +24,7 @@ import type {
VoiceRecordingListResponse,
} from "../../entities/recording/types.js";
import type {
AdminSettings,
AppConfig,
DashboardTab,
UIState,
@@ -49,14 +50,53 @@ class ApiError extends Error {
}
}
// Cache admin password in memory — read from localStorage once on first call
let _cachedPassword: string | null = null;
// Cache admin session token in memory — read from sessionStorage once on first call
// NOTE: _cachedToken is intentionally removed; we read directly from sessionStorage
// to support multi-tab sync (L8 fix).
function getAdminPassword(): string | null {
if (_cachedPassword === null) {
_cachedPassword = localStorage.getItem("admin-password");
}
return _cachedPassword;
/**
* Get the current session token from sessionStorage.
* Always reads directly from sessionStorage to support multi-tab sync.
* Returns null if not authenticated.
*/
export function getSessionToken(): string | null {
return sessionStorage.getItem("admin-token");
}
/**
* Store a session token after successful login.
*/
export function setSessionToken(token: string): void {
sessionStorage.setItem("admin-token", token);
}
/**
* Clear the session token (logout).
*/
export function clearSessionToken(): void {
sessionStorage.removeItem("admin-token");
}
/**
* @deprecated Use getSessionToken() instead.
* Kept for backward compatibility during migration.
*/
export function getAdminPassword(): string | null {
return localStorage.getItem("admin-password");
}
/**
* @deprecated Use setSessionToken() instead.
*/
export function setAdminPassword(password: string): void {
localStorage.setItem("admin-password", password);
}
/**
* @deprecated Use clearSessionToken() instead.
*/
export function clearAdminPassword(): void {
localStorage.removeItem("admin-password");
}
function buildSearchParams(
@@ -76,16 +116,32 @@ export async function request<T>(
init?: RequestInit,
timeoutMs?: number,
): Promise<T> {
const password = getAdminPassword();
const token = getSessionToken();
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
logger.debug("Request", { method: init?.method ?? "GET", url });
const headers: Record<string, string> = {};
// Only set Content-Type for non-FormData bodies
// FormData sets its own Content-Type (multipart/form-data with boundary)
if (!(init?.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
// Prefer Bearer token (new auth method)
if (token) {
headers["Authorization"] = `Bearer ${token}`;
} else {
// Fallback: X-Admin-Password (for backward compatibility)
const password = getAdminPassword();
if (password) {
headers["X-Admin-Password"] = password;
}
}
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
headers,
signal,
...init,
});
@@ -101,6 +157,13 @@ export async function request<T>(
// ignore parse errors
}
logger.error("Request failed", { url, status: res.status, code, message });
// Auto-logout on 401: token expired / invalidated
if (res.status === 401) {
clearSessionToken();
window.location.reload();
}
throw new ApiError(code, message, res.status);
}
@@ -117,6 +180,7 @@ export function getAPIURL(): string {
export type {
ActiveSpeaker,
AdminSettings,
AppConfig,
Channel,
ChatResponse,
@@ -271,13 +335,35 @@ export function deleteRecording(id: string): Promise<void> {
// ─── Auth ────────────────────────────────────────────────────────────────────
export function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/login", {
export function login(password: string): Promise<{ ok: boolean; token?: string }> {
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
});
}
/**
* Server-side logout: increments token version, invalidating all sessions.
* Call this before clearing local state so the token is properly revoked.
*/
export function logout(): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
}
// ─── Admin Settings ──────────────────────────────────────────────────────────
export function getAdminSettings(): Promise<AdminSettings> {
return request<AdminSettings>("/api/admin/settings");
}
export function updateAdminSettings(
patch: Partial<{ dashboardIsPublic: boolean }>,
): Promise<AdminSettings> {
return request<AdminSettings>("/api/admin/settings", {
method: "PATCH",
body: JSON.stringify(patch),
});
}
// ─── Dashboard ─────────────────────────────────────────────────────────────────
export function getDashboardStats(): Promise<DashboardStats> {
@@ -0,0 +1,442 @@
import { AnimatePresence, motion } from "framer-motion";
import {
Command,
FileText,
HelpCircle,
MessageSquare,
Moon,
Search,
Settings,
Sun,
Volume2,
X,
} from "lucide-react";
import type { KeyboardEvent } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
} from "react";
import type { MessageRecord } from "../api/client";
import { request } from "../api/client";
import { cn } from "../lib/utils";
import { Input } from "./index";
/* ─── Modal backdrop variants ──────────────────────────────────────────── */
const backdropVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const modalVariants = {
hidden: { opacity: 0, scale: 0.96, y: 20 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: "spring" as const, stiffness: 350, damping: 28 },
},
exit: { opacity: 0, scale: 0.96, y: 10, transition: { duration: 0.15 } },
} as const;
/* ─── Types ────────────────────────────────────────────────────────────── */
type ModalMode = "search" | "shortcuts" | null;
interface CommandPaletteProps {
isOpen: boolean;
mode: ModalMode;
onClose: () => void;
onNavigate: (tab: string) => void;
onToggleTheme: () => void;
isDark: boolean;
}
const shortcuts = [
{ keys: ["Ctrl", "K"], desc: "Open search" },
{ keys: ["?"], desc: "Show keyboard shortcuts" },
{ keys: ["Esc"], desc: "Close modal / cancel" },
{ keys: ["Ctrl", "1"], desc: "Messages & Moderation" },
{ keys: ["Ctrl", "2"], desc: "Voice & Media" },
{ keys: ["Ctrl", "3"], desc: "Dashboard" },
{ keys: ["Ctrl", "4"], desc: "Settings" },
{ keys: ["Space"], desc: "Push-to-talk (when in voice)" },
{ keys: ["T"], desc: "Toggle theme" },
];
/* ─── Help panel ───────────────────────────────────────────────────────── */
function ShortcutsPanel() {
return (
<div className="space-y-3 p-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-primary" />
Keyboard Shortcuts
</h3>
<div className="grid gap-1.5">
{shortcuts.map((s) => (
<div
key={s.keys.join("+")}
className="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-accent/50 transition-colors"
>
<span className="text-sm text-muted-foreground">{s.desc}</span>
<kbd className="flex items-center gap-1">
{s.keys.map((k) => (
<span
key={k}
className="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-border bg-background px-1.5 text-xs font-mono text-foreground shadow-sm"
>
{k === "Ctrl" ? <Command className="h-3 w-3" /> : k}
</span>
))}
</kbd>
</div>
))}
</div>
</div>
);
}
/* ─── Quick actions ────────────────────────────────────────────────────── */
const quickActions = [
{ id: "messages", label: "Go to Messages", icon: MessageSquare },
{ id: "live", label: "Go to Voice & Media", icon: Volume2 },
{ id: "dashboard", label: "Go to Dashboard", icon: FileText },
{ id: "settings", label: "Open Settings", icon: Settings },
{ id: "theme", label: "Toggle theme", icon: Sun },
];
/* ─── Main component ───────────────────────────────────────────────────── */
export function CommandPalette({
isOpen,
mode,
onClose,
onNavigate,
onToggleTheme,
isDark,
}: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Focus input when search mode opens
useEffect(() => {
if (isOpen && mode === "search") {
// Small delay for the animation to settle
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(focusTimer);
}
}, [isOpen, mode]);
// Reset state when closing
useEffect(() => {
if (!isOpen) {
setQuery("");
setSearchResults([]);
setActiveIndex(0);
}
}, [isOpen]);
const handleSearch = useCallback(async (q: string) => {
setQuery(q);
if (!q.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const params = new URLSearchParams({ q, limit: "10" });
const data = await request<{ results: MessageRecord[] }>(
`/api/analysis/search?${params}`,
);
setSearchResults(data.results || []);
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, []);
const executeAction = useCallback(
(action: string) => {
if (action === "theme") {
onToggleTheme();
} else if (action === "settings") {
onNavigate("settings");
} else if (action === "messages") {
onNavigate("messages");
} else if (action === "live") {
onNavigate("live");
} else if (action === "dashboard") {
onNavigate("dashboard");
}
onClose();
},
[onNavigate, onToggleTheme, onClose],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (i < searchResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (i > 0 ? i - 1 : searchResults.length - 1));
} else if (e.key === "Enter" && searchResults.length > 0) {
onClose();
} else if (e.key === "Escape") {
onClose();
}
},
[searchResults.length, onClose],
);
// Global keyboard listeners for search and help
useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
)
return;
// Ctrl+K — open search
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
// Don't toggle if already open — just close
if (isOpen) {
onClose();
}
return;
}
// ? — show shortcuts (only when no modal is open)
if (e.key === "?" && !isOpen) {
e.preventDefault();
return;
}
// Escape — close any modal
if (e.key === "Escape" && isOpen) {
onClose();
}
// Ctrl+1-4 — tab navigation
if (e.ctrlKey || e.metaKey) {
const tabMap: Record<string, string> = {
"1": "messages",
"2": "live",
"3": "dashboard",
"4": "settings",
};
const tab = tabMap[e.key];
if (tab) {
e.preventDefault();
onNavigate(tab);
}
}
// T — toggle theme (when no input focused)
if (e.key === "t" && !e.ctrlKey && !e.metaKey && !isOpen) {
e.preventDefault();
onToggleTheme();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [isOpen, onClose, onNavigate, onToggleTheme]);
const showSearch = mode === "search";
const showShortcuts = mode === "shortcuts";
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-[9999] flex items-start justify-center pt-[12vh]"
variants={backdropVariants}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: 0.15 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<motion.div
className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-border/50 bg-card shadow-2xl"
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{/* Search header */}
{showSearch && (
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search messages across all channels..."
className="flex-1 border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none"
/>
{isSearching && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
{!isSearching && query && (
<button
onClick={() => {
setQuery("");
setSearchResults([]);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
<kbd className="shrink-0 hidden sm:inline-flex h-5 items-center rounded-md border border-border bg-background px-1.5 text-[10px] font-mono text-muted-foreground">
ESC
</kbd>
</div>
)}
{/* Shortcuts header */}
{showShortcuts && (
<div className="flex items-center justify-between border-b border-border/50 px-4 py-3">
<span className="text-sm font-semibold text-foreground flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-primary" />
Keyboard Shortcuts
</span>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Search results */}
{showSearch && (
<div className="max-h-[320px] overflow-y-auto">
{/* Quick actions */}
{!query && (
<div className="p-2">
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Quick actions
</p>
{quickActions.map((action) => {
const Icon = action.icon;
const isThemeAction = action.id === "theme";
return (
<button
key={action.id}
onClick={() => executeAction(action.id)}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors"
>
<Icon
className={cn(
"h-4 w-4",
isThemeAction && isDark
? "text-amber-400"
: isThemeAction
? "text-indigo-400"
: "text-primary",
)}
/>
<span>{action.label}</span>
{isThemeAction && (
<span className="ml-auto text-xs text-muted-foreground">
{isDark ? "→ Light" : "→ Dark"}
</span>
)}
</button>
);
})}
</div>
)}
{/* Results */}
{query && (
<div className="p-2">
{searchResults.length > 0 ? (
<>
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Messages ({searchResults.length})
</p>
{searchResults.map((msg, i) => (
<button
key={msg.id}
onClick={() => {
onClose();
}}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors",
i === activeIndex
? "bg-accent"
: "hover:bg-accent/50",
)}
>
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{msg.content || "(no content)"}
</p>
<p className="text-xs text-muted-foreground">
{msg.username || msg.user_id || "unknown"}
{msg.ai_status === "flagged" && (
<span className="ml-2 text-destructive">
flagged
</span>
)}
</p>
</div>
</button>
))}
</>
) : (
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
{isSearching
? "Searching..."
: "No messages found matching your query."}
</p>
)}
</div>
)}
{/* Search footer hint */}
{!query && (
<div className="border-t border-border/50 px-4 py-2">
<p className="text-xs text-muted-foreground">
Type to search messages results are fetched from the
server
</p>
</div>
)}
</div>
)}
{/* Shortcuts content */}
{showShortcuts && <ShortcutsPanel />}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,4 +1,5 @@
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
import type { DashboardTab } from "../../entities/ui/types.js";
import { cn } from "../lib/utils";
@@ -6,6 +7,7 @@ const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
{ id: "messages", label: "Messages", Icon: MessageSquare },
{ id: "live", label: "Voice & Media", Icon: Radio },
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
{ id: "settings" as const, label: "Admin", Icon: Settings },
];
interface MobileTabBarProps {
@@ -18,7 +20,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
<nav
aria-label="Main navigation"
role="tablist"
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden"
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg shadow-black/5 md:hidden"
>
{tabs.map(({ id, label, Icon }) => (
<button
@@ -29,16 +31,26 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
type="button"
onClick={() => onTabChange(id)}
className={cn(
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
activeTab === id ? "text-primary" : "text-muted-foreground",
"relative flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
activeTab === id
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-5 w-5" />
{activeTab === id && (
<motion.div
layoutId="tab-indicator"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
/>
)}
<Icon className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")} />
<span className="text-[10px]">{label}</span>
{activeTab === id && (
<span
aria-hidden="true"
className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5"
<motion.div
layoutId="tab-dot"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="h-1 w-1 rounded-full bg-primary mt-0.5"
/>
)}
</button>
@@ -0,0 +1,77 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "../ui/button";
import { cn } from "../lib/utils";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
className?: string;
message?: string;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Error Boundary component catches JavaScript errors in its child tree,
* logs them, and displays a fallback UI instead of crashing the whole app.
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[ErrorBoundary] Caught error:", error.message, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-center",
this.props.className,
)}
role="alert"
>
<AlertTriangle className="mb-3 h-8 w-8 text-destructive" />
<h3 className="mb-1 font-semibold text-foreground">
{this.props.message || "Something went wrong"}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{this.state.error?.message || "An unexpected error occurred."}
</p>
<Button
onClick={this.handleRetry}
variant="outline"
size="sm"
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
Try Again
</Button>
</div>
);
}
return this.props.children;
}
}
+41 -8
View File
@@ -8,6 +8,7 @@ import type {
import { useCallback, useEffect, useRef, useState } from "react";
import type { MediaState } from "../../entities/media/types.js";
import { createLogger } from "../lib/logger.js";
import { getSessionToken } from "../api/client.js";
import type { ActiveSpeakerData } from "./events.js";
const logger = createLogger("socket");
@@ -79,6 +80,7 @@ let _wsInstance: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _closed = false;
let _reconnectAttempts = 0;
let _heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const _listeners = new Set<WsHandlers>();
const _statusCallbacks = new Set<(s: WsStatus) => void>();
@@ -90,7 +92,13 @@ function doConnect(): WebSocket {
const BE_WS_URL =
import.meta.env.VITE_BE_WS_URL ||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
const url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
let url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
// ⚠️ Token is NOT appended to the URL — sending it in the query string
// would leak it into Nginx/Traefik access logs, browser history, and
// Referer headers. Instead, the frontend sends an auth message as the
// first WebSocket frame after connection.
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
dispatchStatus("connecting");
@@ -100,12 +108,32 @@ function doConnect(): WebSocket {
_reconnectAttempts = 0;
dispatchStatus("connected");
logger.info("Connected");
// Send auth message — token is sent as the first WebSocket frame,
// NOT in the URL query string, to avoid exposure in access logs.
const sessionToken = getSessionToken();
if (sessionToken) {
ws.send(JSON.stringify({ type: "auth", token: sessionToken }));
}
// Heartbeat — send a ping every 25s to keep the connection alive
if (_heartbeatInterval) clearInterval(_heartbeatInterval);
_heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 25_000);
});
ws.addEventListener("error", () => {
dispatchStatus("error");
logger.error("WebSocket error");
});
ws.addEventListener("close", (event) => {
// Clean up heartbeat on disconnect
if (_heartbeatInterval) {
clearInterval(_heartbeatInterval);
_heartbeatInterval = null;
}
dispatchStatus("disconnected");
logger.info("Disconnected", { code: event.code, reason: event.reason });
if (!_closed && _listeners.size > 0) {
@@ -338,15 +366,20 @@ export function useDashboardSocket(handlers: WsHandlers) {
ensureConnected();
}
return () => {
return (): void => {
_listeners.delete(wrapper);
_statusCallbacks.delete(setStatus);
if (_listeners.size === 0) {
_closed = true;
if (_reconnectTimer) clearTimeout(_reconnectTimer);
_wsInstance?.close();
_wsInstance = null;
}
// Defer the close so that React Strict Mode double-invoke in dev
// doesn't kill the socket that the remount immediately re-creates.
const delayClose = setTimeout(() => {
if (_listeners.size === 0) {
_closed = true;
if (_reconnectTimer) clearTimeout(_reconnectTimer);
_wsInstance?.close();
_wsInstance = null;
}
}, 100);
delayClose.unref?.();
};
}, []);
+85 -12
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@layer base {
/* ── Light theme (default) ───────────────────────────────────────────── */
:root {
--background: 1 0 0;
--foreground: 0.141 0.005 285.823;
@@ -25,10 +25,49 @@
--primary-glow: 0.623 0.214 259.815 / 0.15;
--accent-glow: 0.552 0.016 285.938 / 0.15;
--card-shadow: 0.92 0.004 286.32 / 0.3;
--particle-primary: 0.623 0.214 259.815 / 0.1;
--particle-secondary: 0.552 0.016 285.938 / 0.1;
--brand-gradient-from: var(--primary);
--brand-gradient-to: 0.623 0.2 200;
--scrollbar-track: 0.967 0.001 286.375;
--scrollbar-thumb: 0.92 0.004 286.32;
}
* {
/* ── Dark theme ──────────────────────────────────────────────────────── */
[data-theme="dark"] {
--background: 0.147 0.004 285.823;
--foreground: 0.92 0.004 286.32;
--card: 0.162 0.008 286.034;
--card-foreground: 0.92 0.004 286.32;
--primary: 0.623 0.214 259.815;
--primary-soft: 0.3 0.04 259.815;
--primary-foreground: 0.97 0.014 254.604;
--secondary: 0.2 0.008 286.034;
--secondary-foreground: 0.85 0.008 286.034;
--muted: 0.2 0.008 286.034;
--muted-foreground: 0.6 0.016 285.938;
--accent: 0.2 0.008 286.034;
--accent-foreground: 0.85 0.008 286.034;
--destructive: 0.577 0.245 27.325;
--destructive-foreground: 0.97 0.014 254.604;
--border: 0.25 0.008 286.034;
--input: 0.25 0.008 286.034;
--ring: 0.623 0.214 259.815;
--primary-glow: 0.623 0.214 259.815 / 0.08;
--accent-glow: 0.552 0.016 285.938 / 0.08;
--card-shadow: 0 0 0 / 0.5;
--particle-primary: 0.623 0.214 259.815 / 0.06;
--particle-secondary: 0.552 0.016 285.938 / 0.06;
--brand-gradient-from: var(--primary);
--brand-gradient-to: 0.7 0.2 220;
--scrollbar-track: 0.147 0.004 285.823;
--scrollbar-thumb: 0.3 0.008 286.034;
}
/* ── Base styles ─────────────────────────────────────────────────────── */
[data-theme] {
border-color: oklch(var(--border));
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
body {
@@ -36,7 +75,7 @@
color: oklch(var(--foreground));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: Outfit, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html,
@@ -44,22 +83,47 @@
#root {
min-height: 100%;
}
/* ── Scrollbar styling ───────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: oklch(var(--scrollbar-track));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--scrollbar-thumb));
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--muted-foreground) / 0.5);
}
/* ── Focus ring consistency ──────────────────────────────────────────── */
:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
}
@layer utilities {
.glass-card {
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
@apply backdrop-blur-sm rounded-xl;
background-color: oklch(var(--card) / 0.7);
border: 1px solid oklch(var(--border));
}
.grid-pattern {
background-image:
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
linear-gradient(oklch(var(--border) / 0.3) 1px, transparent 1px),
linear-gradient(90deg, oklch(var(--border) / 0.3) 1px, transparent 1px);
background-size: 40px 40px;
}
.gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
@apply bg-clip-text text-transparent;
background-image: linear-gradient(to right, oklch(var(--brand-gradient-from)), oklch(var(--brand-gradient-to)));
}
.animate-fade-in-up {
@@ -99,23 +163,32 @@
to { opacity: 1; }
}
@keyframes glow-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.animate-bar-pulse {
animation: bar-pulse 0.4s ease-in-out infinite;
transform-origin: bottom;
}
.animate-shimmer {
background: linear-gradient(
90deg,
oklch(0.92 0.004 286.32 / 0.5) 0%,
oklch(0.967 0.001 286.375) 40%,
oklch(0.92 0.004 286.32 / 0.5) 80%,
oklch(0.92 0.004 286.32 / 0.7) 100%
oklch(var(--border) / 0.5) 0%,
oklch(var(--muted)) 40%,
oklch(var(--border) / 0.5) 80%,
oklch(var(--border) / 0.7) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.animate-glow-pulse {
animation: glow-pulse 4s ease-in-out infinite;
}
/* ── Reduced motion ──────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
@@ -1,8 +1,9 @@
import { motion } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import type { ReactNode } from "react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import type { ThemeMode } from "../hooks/useTheme";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header";
@@ -13,22 +14,30 @@ interface DashboardLayoutProps {
activeTab: DashboardTab;
wsStatus: WsStatus;
voiceStatus: VoiceStatus;
themeMode: ThemeMode;
isDark: boolean;
onTabChange: (tab: DashboardTab) => void;
onThemeToggle: () => void;
children: ReactNode;
recentMessages?: MessageRecord[];
guildId?: string;
channelId?: string;
notificationCount?: number;
}
export function DashboardLayout({
activeTab,
wsStatus,
voiceStatus,
themeMode,
isDark,
onTabChange,
onThemeToggle,
children,
recentMessages = [],
guildId,
channelId,
notificationCount = 0,
}: DashboardLayoutProps) {
return (
<div className="relative min-h-screen bg-background text-foreground">
@@ -46,23 +55,29 @@ export function DashboardLayout({
recentMessages={recentMessages}
guildId={guildId}
channelId={channelId}
notificationCount={notificationCount}
/>
<main className="flex min-w-0 flex-1 flex-col">
<Header
activeTab={activeTab}
wsStatus={wsStatus}
voiceStatus={voiceStatus}
themeMode={themeMode}
isDark={isDark}
onThemeToggle={onThemeToggle}
/>
<motion.main
key={activeTab}
variants={fadeSlideUp}
initial="initial"
animate="animate"
exit="exit"
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
>
{children}
</motion.main>
<AnimatePresence mode="wait">
<motion.main
key={activeTab}
variants={fadeSlideUp}
initial="initial"
animate="animate"
exit="exit"
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8 pb-16 md:pb-0"
>
{children}
</motion.main>
</AnimatePresence>
</main>
</div>
</div>
+23 -3
View File
@@ -1,7 +1,8 @@
import { motion } from "framer-motion";
import { Wifi, WifiOff } from "lucide-react";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import type { ThemeMode } from "../hooks/useTheme";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import { cn } from "../shared/lib/utils";
import { Badge } from "../shared/ui";
@@ -11,18 +12,23 @@ const titles: Record<DashboardTab, string> = {
messages: "Messages & Moderation",
live: "Voice & Media",
dashboard: "Dashboard",
settings: "Admin Settings",
};
const subtitles: Record<DashboardTab, string> = {
messages: "Capture, analyse, and moderate Discord messages.",
live: "Join voice channels, play media, stream audio, and browse recordings.",
dashboard: "Server statistics, user profiles, and AI moderation overview.",
settings: "Manage dashboard visibility, runtime configuration, and authentication.",
};
interface HeaderProps {
activeTab: DashboardTab;
wsStatus: WsStatus;
voiceStatus: VoiceStatus;
themeMode: ThemeMode;
isDark: boolean;
onThemeToggle: () => void;
}
/** Dot indicator colour for WS badge */
@@ -63,7 +69,7 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
);
}
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, onThemeToggle }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -92,8 +98,22 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
</p>
</motion.div>
{/* Right: status badges */}
{/* Right: status badges + theme toggle */}
<div className="flex flex-wrap items-center gap-2">
{/* Theme toggle */}
<button
onClick={onThemeToggle}
className="flex items-center gap-1.5 rounded-lg border border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
title={`Switch to ${isDark ? "light" : "dark"} mode`}
>
{isDark ? (
<Sun className="h-3.5 w-3.5 text-amber-400" />
) : (
<Moon className="h-3.5 w-3.5 text-indigo-400" />
)}
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
</button>
{/* WS Badge */}
<Badge
variant="outline"
+44 -9
View File
@@ -1,5 +1,11 @@
import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import {
Bell,
LayoutDashboard,
MessageSquare,
Radio,
Settings,
} from "lucide-react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import { useMascotChat } from "../shared/hooks/useMascotChat";
@@ -7,12 +13,16 @@ import { cn } from "../shared/lib/utils";
import { MascotChatbot } from "./mascot/MascotChatbot";
import { MascotImage } from "./mascot/MascotImage";
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
[
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
{ id: "live", label: "Voice & Media", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
];
const navItems: Array<{
id: DashboardTab;
label: string;
icon: typeof Radio;
}> = [
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
{ id: "live", label: "Voice & Media", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ id: "settings" as const, label: "Admin", icon: Settings },
];
interface SidebarProps {
activeTab: DashboardTab;
@@ -21,6 +31,7 @@ interface SidebarProps {
recentMessages?: MessageRecord[];
guildId?: string;
channelId?: string;
notificationCount?: number;
}
export function Sidebar({
@@ -30,6 +41,7 @@ export function Sidebar({
recentMessages = [],
guildId,
channelId,
notificationCount = 0,
}: SidebarProps) {
const mascotChat = useMascotChat({
messageCount: recentMessages.length,
@@ -46,7 +58,7 @@ export function Sidebar({
<motion.nav
className={cn(
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
collapsed ? "w-16" : "w-64",
collapsed ? "w-16" : "w-56 lg:w-64",
)}
layout
transition={{ type: "spring", stiffness: 300, damping: 30 }}
@@ -62,6 +74,10 @@ export function Sidebar({
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
alt="IMPHNEN"
className="h-8 w-8 rounded-xl"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
{/* Mascot image — only when expanded */}
@@ -70,6 +86,10 @@ export function Sidebar({
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot"
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
)}
</div>
@@ -95,6 +115,21 @@ export function Sidebar({
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
{!collapsed && item.id === "messages" &&
notificationCount !== undefined &&
notificationCount > 0 && (
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{notificationCount > 99 ? "99+" : notificationCount}
</span>
)}
{/* Collapsed badge — top-right dot */}
{collapsed && item.id === "messages" &&
notificationCount !== undefined &&
notificationCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[7px] font-bold text-destructive-foreground">
{notificationCount > 9 ? "N" : notificationCount}
</span>
)}
</button>
);
})}
@@ -118,7 +153,7 @@ export function Sidebar({
onClose={() => mascotChat.setIsOpen(false)}
onSendMessage={mascotChat.handleSendMessage}
mascotName="IMPHNEN Mascot"
className="fixed bottom-[170px] left-[80px] z-[9999]"
className="fixed bottom-[170px] left-[80px] z-[9999] md:bottom-4 md:left-4 md:right-auto"
/>
</>
);
@@ -117,14 +117,14 @@ export function MascotChatbot({
)}
>
{/* Header */}
<div className="bg-gradient-to-r from-primary to-primary/80 text-white p-4 flex items-center justify-between">
<div className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-primary-foreground/20 flex items-center justify-center">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-sm">{mascotName}</h3>
<p className="text-xs text-white/80">
<p className="text-xs text-primary-foreground/80">
{loading ? "Mengetik..." : "Online"}
</p>
</div>
@@ -134,7 +134,7 @@ export function MascotChatbot({
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
title={isMinimized ? "Maximize" : "Minimize"}
>
{isMinimized ? (
@@ -149,7 +149,7 @@ export function MascotChatbot({
onClick={() => {
onClose?.();
}}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
title="Close"
>
<X className="h-4 w-4" />
@@ -176,6 +176,10 @@ export function MascotChatbot({
src={mascotAvatar}
alt={mascotName}
className="w-6 h-6 rounded-full object-cover"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
)}
<div
@@ -200,6 +204,10 @@ export function MascotChatbot({
src={mascotAvatar}
alt={mascotName}
className="w-6 h-6 rounded-full object-cover"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
<div className="flex gap-1">
@@ -38,6 +38,7 @@ export function MascotImage({
const sizeClass = sizeMap[size];
const chatSizeClass = chatSizeMap[size];
const [isVisible, setIsVisible] = useState(false);
const [imgError, setImgError] = useState(false);
useEffect(() => {
if (showChat && chatMessage) {
@@ -51,13 +52,20 @@ export function MascotImage({
return (
<div className="relative inline-block">
<motion.img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot"
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
{imgError ? (
<div className={`flex items-center justify-center ${sizeClass} bg-muted/30 rounded-xl`}>
<MessageCircle className="h-6 w-6 text-muted-foreground/50" />
</div>
) : (
<motion.img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot"
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
onError={() => setImgError(true)}
/>
)}
{/* Floating Chat Bubble */}
{isVisible && chatMessage && (
@@ -72,7 +80,7 @@ export function MascotImage({
{/* Chat bubble */}
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
<div className="flex items-start gap-2">
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-primary-foreground/80" />
<p className="text-xs leading-relaxed font-medium line-clamp-3">
{chatMessage}
</p>
@@ -20,11 +20,16 @@ export function ParticleBackground() {
style={{ zIndex: -1 }}
>
{/* Top-right glow orb */}
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
style={{ backgroundColor: "oklch(var(--particle-primary, 0.623 0.214 259.815 / 0.1))" }}
/>
{/* Bottom-left glow orb */}
<div
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse"
style={{ animationDelay: "1.5s" }}
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl animate-glow-pulse"
style={{
backgroundColor: "oklch(var(--particle-secondary, 0.552 0.016 285.938 / 0.1))",
animationDelay: "1.5s",
}}
/>
</div>
);
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
+7 -3
View File
@@ -6,11 +6,15 @@
"strict": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"types": ["astro/client"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
}
},
"include": ["src/**/*", "vite.config.ts"]
"include": ["src/**/*", "astro.config.mjs"]
}
-25
View File
@@ -1,25 +0,0 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
build: {
rolldownOptions: {
checks: {
pluginTimings: false,
},
},
},
server: {
middlewareMode: false,
},
preview: {
port: 3000,
host: true,
allowedHosts: [
"imphnen.asepharyana.my.id",
"imphnen.asepharyana.tech",
"imphnen.asepharyana.web.id",
],
},
});