feat: update dependencies and improve dashboard functionality
Deploy to VPS / deploy (push) Failing after 1m43s

- Added new dependencies for Next.js and lucide-react in pnpm-workspace.yaml.
- Refactored DashboardPage component to improve readability and error handling.
- Enhanced Header component to display error status with an alert icon.
- Updated MobileTabBar and Sidebar components to use a centralized tabs definition.
- Improved ChannelsView in dashboard-panel to handle channel fetching more cleanly.
- Fixed ActiveSpeaker type to use camelCase for userId.
- Updated MessagesPanel to handle guildId checks more gracefully.
- Adjusted API calls in dashboard and messages to align with backend expectations.
- Refined type definitions across various interfaces for consistency and clarity.
This commit is contained in:
asepharyana
2026-07-26 14:27:36 +07:00
parent 9ecc4a6caa
commit 0a6a9fd982
62 changed files with 2764 additions and 305 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@@ -30,7 +30,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"style": { "style": {
"noNonNullAssertion": "warn", "noNonNullAssertion": "warn",
"useNodejsImportProtocol": "warn" "useNodejsImportProtocol": "warn"
+1 -1
View File
@@ -23,7 +23,7 @@
"install:yt-dlp": "sh scripts/install-yt-dlp.sh" "install:yt-dlp": "sh scripts/install-yt-dlp.sh"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "^2.5.5",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.22.2", "tsx": "^4.22.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+2564 -91
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -19,6 +19,18 @@ minimumReleaseAgeExclude:
- pg-protocol@1.14.0 - pg-protocol@1.14.0
- pg@8.21.0 - pg@8.21.0
- tsx@4.22.2 - tsx@4.22.2
- '@next/env@16.2.12'
- '@next/swc-darwin-arm64@16.2.12'
- '@next/swc-darwin-x64@16.2.12'
- '@next/swc-linux-arm64-gnu@16.2.12'
- '@next/swc-linux-arm64-musl@16.2.12'
- '@next/swc-linux-x64-gnu@16.2.12'
- '@next/swc-linux-x64-musl@16.2.12'
- '@next/swc-win32-arm64-msvc@16.2.12'
- '@next/swc-win32-x64-msvc@16.2.12'
- lucide-react@1.27.0
- next@16.2.12
- shadcn@4.15.0
onlyBuiltDependencies: onlyBuiltDependencies:
- '@discordjs/opus' - '@discordjs/opus'
+1 -1
View File
@@ -28,7 +28,7 @@ async function shutdown(signal: string) {
// 1. Stop accepting new HTTP connections // 1. Stop accepting new HTTP connections
if (httpServer) { if (httpServer) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
httpServer!.close(() => { httpServer?.close(() => {
logger.info("HTTP server closed"); logger.info("HTTP server closed");
resolve(); resolve();
}); });
@@ -2,7 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { getPool } from "../../shared/database/index.js"; import { getPool } from "../../shared/database/index.js";
import type { ListUsersQuery } from "./dashboard.service.js"; import type { ListUsersQuery } from "./dashboard.service.js";
const logger = createChildLogger("dashboard.repository"); const _logger = createChildLogger("dashboard.repository");
export class DashboardRepository { export class DashboardRepository {
async getStats() { async getStats() {
@@ -1,6 +1,6 @@
import type { Router } from "express"; import type { Router } from "express";
import express from "express"; import express from "express";
import { collectDefaultMetrics, register } from "prom-client"; import { collectDefaultMetrics } from "prom-client";
import { handleHealthCheck, handleMetrics } from "./health.controller.js"; import { handleHealthCheck, handleMetrics } from "./health.controller.js";
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.) // Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
@@ -1,7 +1,7 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js"; import { healthRepository } from "./health.repository.js";
const logger = createChildLogger("health.service"); const _logger = createChildLogger("health.service");
export class HealthService { export class HealthService {
async getHealth(verbose = false) { async getHealth(verbose = false) {
@@ -4,7 +4,7 @@
* Prometheus metrics for AI moderation pipeline. * Prometheus metrics for AI moderation pipeline.
* Defined in backend (where prom-client is installed + /api/metrics endpoint). * Defined in backend (where prom-client is installed + /api/metrics endpoint).
*/ */
import { Counter, Histogram, register } from "prom-client"; import { Counter, Histogram } from "prom-client";
// ── LLM Call Metrics ── // ── LLM Call Metrics ──
export const llmCallsTotal = new Counter({ export const llmCallsTotal = new Counter({
@@ -56,7 +56,7 @@ export const handleMascotChat = asyncHandler(
export const getMascotChatHistory = asyncHandler( export const getMascotChatHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await mascotChatService.getChatHistory(userId, limit); const history = await mascotChatService.getChatHistory(userId, limit);
@@ -66,7 +66,7 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
type MediaReplyData = Record<string, unknown> | MediaState; type MediaReplyData = Record<string, unknown> | MediaState;
function fromReply(data: MediaReplyData): MediaState { function _fromReply(data: MediaReplyData): MediaState {
return normalizeMediaState(data as Record<string, unknown>); return normalizeMediaState(data as Record<string, unknown>);
} }
@@ -68,7 +68,7 @@ export class RecordingsService {
const items = rows.slice(0, limit) as unknown as RecordingRow[]; const items = rows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = rows.length > limit; const hasMore = rows.length > limit;
const nextCursor = hasMore const nextCursor = hasMore
? String(items[items.length - 1]!.created_at) ? String(items[items.length - 1]?.created_at)
: null; : null;
return { items, nextCursor, hasMore }; return { items, nextCursor, hasMore };
@@ -1,6 +1,5 @@
import type { CommandReply } from "@bete/shared"; import type { CommandReply } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { publishCommand, readRedisStatus } from "./redis/index.js";
export { createChildLogger }; export { createChildLogger };
+1 -1
View File
@@ -15,7 +15,7 @@ import {
pageResult, pageResult,
retryWithBackoff, retryWithBackoff,
} from "@bete/shared/utils"; } from "@bete/shared/utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
// ─── Backend middleware ────────────────────────────────────────────────────── // ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js"; import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
@@ -24,10 +24,7 @@ import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
import { registerThreadCapture } from "../modules/thread-tracking/index.js"; import { registerThreadCapture } from "../modules/thread-tracking/index.js";
import { registerPresenceCapture } from "../modules/user-presence/index.js"; import { registerPresenceCapture } from "../modules/user-presence/index.js";
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js"; import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
startMuxerWorker,
stopMuxerWorker,
} from "../modules/voice-recording/muxer.js";
import { import {
setPcmWsClient, setPcmWsClient,
setEventBroadcaster as setRecorderEventBroadcaster, setEventBroadcaster as setRecorderEventBroadcaster,
@@ -19,7 +19,6 @@ import {
} from "./batchProcessor.js"; } from "./batchProcessor.js";
import { scheduleConversationAnalysis } from "./batchScheduler.js"; import { scheduleConversationAnalysis } from "./batchScheduler.js";
import { import {
_redisEventBroadcaster,
broadcastAnalysisCompleted, broadcastAnalysisCompleted,
conversationConsecutiveErrors, conversationConsecutiveErrors,
conversationDebounceTimers, conversationDebounceTimers,
@@ -16,13 +16,13 @@ let activeCount = 0;
let pendingCount = 0; let pendingCount = 0;
// Track queue state changes for logging // Track queue state changes for logging
function updateCounts(): void { function _updateCounts(): void {
// p-limit exposes queueSize and activeCount via constructor internals, // p-limit exposes queueSize and activeCount via constructor internals,
// but we track via our wrapper to avoid depending on internals. // but we track via our wrapper to avoid depending on internals.
} }
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> { export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
const queuedAt = activeCount + pendingCount; const _queuedAt = activeCount + pendingCount;
pendingCount++; pendingCount++;
logger.debug( logger.debug(
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT }, { activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import { import { messagesTable } from "../../shared/database/schema.js";
channelCulturesTable,
messagesTable,
} from "../../shared/database/schema.js";
import { updateChannelCulture } from "./channelCultureStore.js"; import { updateChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
@@ -550,7 +550,7 @@ async function downloadMediaCandidate(
imageMap: Map<string, MessageImagePart[]>, imageMap: Map<string, MessageImagePart[]>,
mediaAnalysisMap: Map<string, string[]>, mediaAnalysisMap: Map<string, string[]>,
): Promise<void> { ): Promise<void> {
const log = createChildLogger("mediaAnalysis"); const _log = createChildLogger("mediaAnalysis");
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return; if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
if (candidate.customEmojiId || candidate.stickerName) { if (candidate.customEmojiId || candidate.stickerName) {
@@ -646,7 +646,7 @@ export async function prepareMediaMessage(
target: MessageRecord, target: MessageRecord,
allAttachments: AttachmentRecord[] | undefined, allAttachments: AttachmentRecord[] | undefined,
): Promise<PreparedMediaMessage> { ): Promise<PreparedMediaMessage> {
const log = createChildLogger("mediaAnalysis"); const _log = createChildLogger("mediaAnalysis");
const targetId = target.id; const targetId = target.id;
const imageMap = new Map<string, MessageImagePart[]>(); const imageMap = new Map<string, MessageImagePart[]>();
const webTextMap = new Map<string, string[]>(); const webTextMap = new Map<string, string[]>();
@@ -10,7 +10,6 @@ import { delay, retryWithBackoff } from "@bete/shared/utils";
import type { ChatCompletion } from "openai/resources/chat/completions"; import type { ChatCompletion } from "openai/resources/chat/completions";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js"; import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
import { getMessageById } from "../message-capture/messageStore.js";
import type { import type {
AnalysisResult, AnalysisResult,
AttachmentRecord, AttachmentRecord,
@@ -18,15 +17,7 @@ import type {
} from "../message-capture/types.js"; } from "../message-capture/types.js";
import { getChannelCulture } from "./channelCultureStore.js"; import { getChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
import type { import { hasMediaContent, prepareMediaMessage } from "./mediaAnalysisClient.js";
MessageImagePart,
PreparedMediaMessage,
} from "./mediaAnalysisClient.js";
import {
analyzeSingleMediaImage,
hasMediaContent,
prepareMediaMessage,
} from "./mediaAnalysisClient.js";
import { import {
buildReferenceXml, buildReferenceXml,
escapeXml, escapeXml,
@@ -816,7 +807,7 @@ export async function runSimpleTextFallback(
const MAX_CONTENT_CHARS = 500; const MAX_CONTENT_CHARS = 500;
const truncatedContent = const truncatedContent =
content.length > MAX_CONTENT_CHARS content.length > MAX_CONTENT_CHARS
? content.slice(0, MAX_CONTENT_CHARS) + "..." ? `${content.slice(0, MAX_CONTENT_CHARS)}...`
: content; : content;
let userProfileCtx = ""; let userProfileCtx = "";
@@ -876,7 +867,7 @@ Jawab HANYA dengan satu kata: clean, warn, atau flagged`;
let category = ""; let category = "";
if (status === "clean") { if (status === "clean") {
analysis = `${message.username ?? "user"}: ${content.length > 200 ? content.slice(0, 200) + "..." : content}. Percakapan normal, tidak ada pelanggaran.`; analysis = `${message.username ?? "user"}: ${content.length > 200 ? `${content.slice(0, 200)}...` : content}. Percakapan normal, tidak ada pelanggaran.`;
} else { } else {
category = status === "flagged" ? "harassment" : "spam"; category = status === "flagged" ? "harassment" : "spam";
const categoryOptions = const categoryOptions =
@@ -958,7 +949,7 @@ Kategori: spam`;
policyVersion: "default-simple-2026-06", policyVersion: "default-simple-2026-06",
evidence: evidence:
status !== "clean" status !== "clean"
? [content.length > 120 ? content.slice(0, 120) + "..." : content] ? [content.length > 120 ? `${content.slice(0, 120)}...` : content]
: [], : [],
}; };
} }
@@ -795,7 +795,7 @@ export function sanitizeAiContent(
// 3. Cap length // 3. Cap length
const capped = const capped =
escaped.length > maxLen escaped.length > maxLen
? escaped.slice(0, maxLen) + "…[truncated]" ? `${escaped.slice(0, maxLen)}…[truncated]`
: escaped; : escaped;
// 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts) // 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts)
@@ -4,8 +4,6 @@ import { extractJson } from "./jsonExtractor.js";
import { ModerationResponseSchema } from "./moderationSchemas.js"; import { ModerationResponseSchema } from "./moderationSchemas.js";
import { import {
clampScore, clampScore,
DEFERRAL_ANALYSIS_PATTERN,
DEFERRAL_EXCEPTION_PATTERN,
deriveRecommendedAction, deriveRecommendedAction,
deriveSeverity, deriveSeverity,
hasDeferralAnalysis, hasDeferralAnalysis,
@@ -46,7 +44,7 @@ export function parseModerationResponse(
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(content); parsed = JSON.parse(content);
} catch (e) { } catch (_e) {
parsed = extractJson(content); parsed = extractJson(content);
} }
@@ -90,7 +90,7 @@ export function logModerationAnalysis(
}, },
parseErrors: string[] = [], parseErrors: string[] = [],
): void { ): void {
const response: ModerationAnalysisResponse = { const _response: ModerationAnalysisResponse = {
messageIds, messageIds,
batchSize: messageIds.length, batchSize: messageIds.length,
model, model,
@@ -158,7 +158,7 @@ export function logCacheEvent(
cacheKey: string, cacheKey: string,
source: "text" | "media" | "sticker", source: "text" | "media" | "sticker",
): void { ): void {
const event: CacheHitEvent = { const _event: CacheHitEvent = {
type, type,
cacheKey, cacheKey,
source, source,
@@ -260,7 +260,7 @@ export function logAnalysisSummary(
duration_ms: durationMs, duration_ms: durationMs,
per_message_avg_ms: Math.round(durationMs / totalMessages), per_message_avg_ms: Math.round(durationMs / totalMessages),
summary, summary,
success_rate: ((successCount / totalMessages) * 100).toFixed(1) + "%", success_rate: `${((successCount / totalMessages) * 100).toFixed(1)}%`,
}, },
`Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`, `Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`,
); );
@@ -86,7 +86,7 @@ export async function incrementTextCacheHit(text: string): Promise<void> {
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`, `UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
[text], [text],
); );
} catch (error) { } catch (_error) {
// Silent fail — this is just a counter, not critical // Silent fail — this is just a counter, not critical
} }
} }
@@ -3,7 +3,7 @@ import { isIP } from "node:net";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { createAbortControllerWithTimeout } from "@bete/shared/utils"; import { createAbortControllerWithTimeout } from "@bete/shared/utils";
const log = createChildLogger("urlFetcher"); const _log = createChildLogger("urlFetcher");
export interface FetchedUrlContext { export interface FetchedUrlContext {
url: string; url: string;
@@ -53,14 +53,14 @@ async function isSafeUrl(urlStr: string): Promise<boolean> {
return false; return false;
} }
} }
} catch (err) { } catch (_err) {
// If DNS fails, we can't fetch it anyway // If DNS fails, we can't fetch it anyway
return false; return false;
} }
} }
return true; return true;
} catch (err) { } catch (_err) {
return false; return false;
} }
} }
@@ -70,7 +70,7 @@ function extractOgImage(html: string): string | null {
const ogRegex = const ogRegex =
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; /<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
const match = html.match(ogRegex); const match = html.match(ogRegex);
if (match && match[1]) { if (match?.[1]) {
// Unescape basic HTML entities // Unescape basic HTML entities
return match[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'); return match[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
} }
@@ -79,7 +79,7 @@ function extractOgImage(html: string): string | null {
const ogRegexRev = const ogRegexRev =
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; /<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
const matchRev = html.match(ogRegexRev); const matchRev = html.match(ogRegexRev);
if (matchRev && matchRev[1]) { if (matchRev?.[1]) {
return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'); return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
} }
@@ -164,7 +164,7 @@ export async function fetchUrlSafely(
// If it's HTML, try to find an og:image first (for Tenor/Giphy etc) // If it's HTML, try to find an og:image first (for Tenor/Giphy etc)
if (contentType.startsWith("text/html")) { if (contentType.startsWith("text/html")) {
const ogImage = extractOgImage(text); const ogImage = extractOgImage(text);
if (ogImage && ogImage.startsWith("http")) { if (ogImage?.startsWith("http")) {
// Fetch the og:image instead // Fetch the og:image instead
return fetchUrlSafely(ogImage, depth + 1); return fetchUrlSafely(ogImage, depth + 1);
} }
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import { import { messagesTable } from "../../shared/database/schema.js";
messagesTable,
userProfilesTable,
} from "../../shared/database/schema.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
import { updateUserProfile } from "./userProfileStore.js"; import { updateUserProfile } from "./userProfileStore.js";
@@ -48,7 +45,7 @@ async function learnUserProfile(
for (const msg of recentMessages) { for (const msg of recentMessages) {
const ch = msg.channelId ?? "unknown"; const ch = msg.channelId ?? "unknown";
if (!channelGroups.has(ch)) channelGroups.set(ch, []); if (!channelGroups.has(ch)) channelGroups.set(ch, []);
channelGroups.get(ch)!.push(msg); channelGroups.get(ch)?.push(msg);
} }
// Build messages text with channel context // Build messages text with channel context
@@ -9,10 +9,7 @@ import { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis"; import Redis from "ioredis";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import type { import type { VoiceController } from "../voice-recording/voiceController.js";
VoiceController,
VoiceStatus,
} from "../voice-recording/voiceController.js";
import { GuildHandler } from "./guild.handler.js"; import { GuildHandler } from "./guild.handler.js";
import { import {
type CommandHandlerFn, type CommandHandlerFn,
@@ -1,8 +1,4 @@
import { import type { CommandMessage, CommandReply } from "@bete/shared";
COMMAND_VOICE_DISCONNECT_GUILD,
type CommandMessage,
type CommandReply,
} from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
@@ -1,6 +1,5 @@
import http from "node:http"; import http from "node:http";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
const logger = createChildLogger("gateway-metrics"); const logger = createChildLogger("gateway-metrics");
@@ -82,7 +81,7 @@ function formatMetrics(): string {
lines.push(`${fullName} ${metric.value}`); lines.push(`${fullName} ${metric.value}`);
} }
return lines.join("\n") + "\n"; return `${lines.join("\n")}\n`;
} }
export function startMetricsServer(): void { export function startMetricsServer(): void {
@@ -1,4 +1,3 @@
import { decodeCursor, encodeCursor } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
@@ -1,15 +1,5 @@
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
and,
asc,
desc,
eq,
inArray,
isNull,
or,
type SQL,
sql,
} from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js"; import type * as schema from "../../shared/database/schema.js";
import { messagesTable } from "../../shared/database/schema.js"; import { messagesTable } from "../../shared/database/schema.js";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,14 +1,8 @@
import type fs from "node:fs"; import type fs from "node:fs";
import type { import type {
AIRecommendedAction,
AISeverity,
AIStatus,
AnalysisQueueStatus, AnalysisQueueStatus,
AttachmentRecord, AttachmentRecord,
BroadcasterClient,
MessageRecord, MessageRecord,
ModerationBroadcaster,
RoleMetadata,
UserMetadata, UserMetadata,
VoiceRecordingUploadData, VoiceRecordingUploadData,
} from "@bete/shared"; } from "@bete/shared";
@@ -225,7 +225,7 @@ export function resolveMediaUrl(
// -- stderr (capture for diagnostics, capped at 4KB) ---------------------------------- // -- stderr (capture for diagnostics, capped at 4KB) ----------------------------------
const MAX_STDERR = 4096; const _MAX_STDERR = 4096;
if (proc.stderr) { if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => { proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8"); stderrBuf += chunk.toString("utf8");
@@ -1,7 +1,6 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import type * as schema from "../../shared/database/schema.js"; import type * as schema from "../../shared/database/schema.js";
import { muxerJobsTable } from "../../shared/database/schema.js"; import { muxerJobsTable } from "../../shared/database/schema.js";
@@ -1,7 +1,7 @@
import { Transform, type TransformCallback } from "node:stream"; import { Transform, type TransformCallback } from "node:stream";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("packet-filter"); const _logger = createChildLogger("packet-filter");
/** /**
* Transform stream to filter out audio packets that are too small. * Transform stream to filter out audio packets that are too small.
@@ -19,7 +19,7 @@ export class PacketFilter extends Transform {
_transform( _transform(
chunk: Buffer, chunk: Buffer,
encoding: string, _encoding: string,
callback: TransformCallback, callback: TransformCallback,
): void { ): void {
this.totalCount++; this.totalCount++;
@@ -16,7 +16,6 @@ import type { VoicePcmWsClient } from "../voice-pcm-ws/index.js";
import { import {
createRecordingSession, createRecordingSession,
type RecordingSession, type RecordingSession,
type SessionRecordingMetadata,
} from "./recorder/sessionRecording.js"; } from "./recorder/sessionRecording.js";
import { createSpeakingHandler } from "./recorder/speakingHandler.js"; import { createSpeakingHandler } from "./recorder/speakingHandler.js";
@@ -141,7 +140,7 @@ export async function startRecording(
activeSessions, activeSessions,
recordingsDir, recordingsDir,
pcmSender: _pcmWsClient pcmSender: _pcmWsClient
? (pcm, userId) => _pcmWsClient!.sendPcm(userId, pcm) ? (pcm, userId) => _pcmWsClient?.sendPcm(userId, pcm)
: undefined, : undefined,
}); });
@@ -23,7 +23,6 @@ export class VoiceTransmitter {
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT; private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
/** Queue for PCM chunks when backpressure is active */ /** Queue for PCM chunks when backpressure is active */
private backpressureQueue: Buffer[] = []; private backpressureQueue: Buffer[] = [];
private draining = false;
/** Serialise start/stop to prevent races between rapid toggle commands */ /** Serialise start/stop to prevent races between rapid toggle commands */
private gate = Promise.resolve(); private gate = Promise.resolve();
/** Set true before sending SIGTERM so exit handler knows it's intentional */ /** Set true before sending SIGTERM so exit handler knows it's intentional */
@@ -176,7 +175,7 @@ export class VoiceTransmitter {
logger.info("Voice transmitter started"); logger.info("Voice transmitter started");
} finally { } finally {
release!(); release?.();
} }
} }
@@ -227,7 +226,7 @@ export class VoiceTransmitter {
discordPlayer.stop("browser-bridge"); discordPlayer.stop("browser-bridge");
logger.info("Voice transmitter stopped"); logger.info("Voice transmitter stopped");
} finally { } finally {
release!(); release?.();
} }
} }
@@ -1,6 +1,6 @@
import { AppError } from "@bete/shared/errors"; import { AppError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; import type { VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { discordPlayer } from "./player.js"; import { discordPlayer } from "./player.js";
import { startRecording, stopRecording } from "./recorder.js"; import { startRecording, stopRecording } from "./recorder.js";
@@ -1,5 +1,4 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
const logger = createChildLogger("webhook-notifier"); const logger = createChildLogger("webhook-notifier");
@@ -2,7 +2,6 @@ import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
import { import {
bigint as pgBigint, bigint as pgBigint,
boolean as pgBoolean, boolean as pgBoolean,
foreignKey as pgForeignKey,
index as pgIndex, index as pgIndex,
integer as pgInteger, integer as pgInteger,
jsonb as pgJsonb, jsonb as pgJsonb,
+26 -16
View File
@@ -1,14 +1,14 @@
"use client"; "use client";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { voiceApi } from "@/lib/api"; import { useCallback, useEffect, useState } from "react";
import { useAppConfig } from "@/lib/hooks/use-config";
import type { Guild } from "@/lib/types";
import { DashboardPanel } from "@/features/dashboard/dashboard-panel"; import { DashboardPanel } from "@/features/dashboard/dashboard-panel";
import { LivePanel } from "@/features/live/live-panel"; import { LivePanel } from "@/features/live/live-panel";
import { MessagesPanel } from "@/features/messages/messages-panel"; import { MessagesPanel } from "@/features/messages/messages-panel";
import { voiceApi } from "@/lib/api";
import { useAppConfig } from "@/lib/hooks/use-config";
import type { Guild } from "@/lib/types";
export default function DashboardPage() { export default function DashboardPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -45,12 +45,17 @@ export default function DashboardPage() {
if (!cancelled) setGuilds(g); if (!cancelled) setGuilds(g);
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"); if (!cancelled)
setGuildsError(
err instanceof Error ? err.message : "Failed to load guilds",
);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setGuildsLoading(false); if (!cancelled) setGuildsLoading(false);
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
// Resolve guild ID once config and guilds are loaded // Resolve guild ID once config and guilds are loaded
@@ -80,9 +85,15 @@ export default function DashboardPage() {
onRetry={() => { onRetry={() => {
setGuildsLoading(true); setGuildsLoading(true);
setGuildsError(null); setGuildsError(null);
voiceApi.getGuilds().then(setGuilds).catch( voiceApi
(err) => setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"), .getGuilds()
).finally(() => setGuildsLoading(false)); .then(setGuilds)
.catch((err) =>
setGuildsError(
err instanceof Error ? err.message : "Failed to load guilds",
),
)
.finally(() => setGuildsLoading(false));
}} }}
/> />
@@ -90,12 +101,8 @@ export default function DashboardPage() {
{isReady ? ( {isReady ? (
<> <>
{tab === "live" && <LivePanel />} {tab === "live" && <LivePanel />}
{tab === "dashboard" && ( {tab === "dashboard" && <DashboardPanel guildId={selectedGuildId} />}
<DashboardPanel guildId={selectedGuildId} /> {tab === "messages" && <MessagesPanel guildId={selectedGuildId} />}
)}
{tab === "messages" && (
<MessagesPanel guildId={selectedGuildId} />
)}
</> </>
) : ( ) : (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -165,7 +172,10 @@ function GuildBar({
return ( return (
<div className="flex items-center gap-2 rounded-lg border p-3"> <div className="flex items-center gap-2 rounded-lg border p-3">
<label htmlFor="guild-select" className="text-sm font-medium text-muted-foreground whitespace-nowrap"> <label
htmlFor="guild-select"
className="text-sm font-medium text-muted-foreground whitespace-nowrap"
>
Guild: Guild:
</label> </label>
<select <select
+4 -10
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -29,16 +30,9 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>
<script <Script id="theme-script" strategy="beforeInteractive">
dangerouslySetInnerHTML={{ {`try{const t=localStorage.getItem('theme')||'dark';document.documentElement.classList.add(t)}catch(e){}`}
__html: ` </Script>
try {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.classList.add(theme);
} catch(e) {}
`,
}}
/>
</head> </head>
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react"; import { AlertCircle, Moon, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
@@ -37,6 +37,11 @@ export function Header() {
<Wifi className="size-3 text-yellow-500" /> <Wifi className="size-3 text-yellow-500" />
<span className="hidden sm:inline">Connecting</span> <span className="hidden sm:inline">Connecting</span>
</> </>
) : status === "error" ? (
<>
<AlertCircle className="size-3 text-destructive" />
<span className="hidden sm:inline">Error</span>
</>
) : ( ) : (
<> <>
<WifiOff className="size-3 text-destructive" /> <WifiOff className="size-3 text-destructive" />
@@ -1,18 +1,11 @@
"use client"; "use client";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation"; import { type TabId, tabs } from "@/lib/tabs";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function MobileTabBar({ activeTab }: { activeTab: TabId }) { export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
return ( return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background"> <nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background">
@@ -21,7 +14,11 @@ export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => router.push(`/dashboard?tab=${id}`)} onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", id);
router.push(`/dashboard?${params}`);
}}
data-active={activeTab === id ? "" : undefined} data-active={activeTab === id ? "" : undefined}
className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors" className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors"
> >
@@ -1,21 +1,17 @@
"use client"; "use client";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { Radio } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { type TabId, tabs } from "@/lib/tabs";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function Sidebar({ activeTab }: { activeTab: TabId }) { export function Sidebar({ activeTab }: { activeTab: TabId }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const handleTabClick = (tabId: TabId) => { const handleTabClick = (tabId: TabId) => {
router.push(`/dashboard?tab=${tabId}`); const params = new URLSearchParams(searchParams.toString());
params.set("tab", tabId);
router.push(`/dashboard?${params}`);
}; };
return ( return (
@@ -11,6 +11,7 @@ import {
Shield, Shield,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { dashboardApi } from "@/lib/api"; import { dashboardApi } from "@/lib/api";
import type { import type {
@@ -123,7 +124,7 @@ export function DashboardPanel({ guildId }: { guildId: string }) {
// ── Stats View ──────────────────────────────────────────── // ── Stats View ────────────────────────────────────────────
function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) { function StatsView(_props: { onNavigate: (view: View) => void }) {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -166,8 +167,8 @@ function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) {
{/* Metric cards */} {/* Metric cards */}
{loading ? ( {loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }, (_, i) => `stat-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="h-3 w-16 bg-muted rounded animate-pulse" /> <div className="h-3 w-16 bg-muted rounded animate-pulse" />
<div className="h-8 w-20 bg-muted rounded animate-pulse" /> <div className="h-8 w-20 bg-muted rounded animate-pulse" />
</div> </div>
@@ -339,8 +340,8 @@ function UsersView({
{loading ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }, (_, i) => `user-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-muted animate-pulse" /> <div className="size-10 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
@@ -362,9 +363,11 @@ function UsersView({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden"> <div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
{user.avatar_url ? ( {user.avatar_url ? (
<img <Image
src={user.avatar_url} src={user.avatar_url}
alt="" alt=""
width={40}
height={40}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -407,21 +410,24 @@ function ChannelsView({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const fetchChannels = useCallback(async (searchQuery?: string) => { const fetchChannels = useCallback(
setLoading(true); async (searchQuery?: string) => {
try { setLoading(true);
const result = await dashboardApi.listChannels( try {
20, const result = await dashboardApi.listChannels(
searchQuery, 20,
guildId || undefined, searchQuery,
); guildId || undefined,
setChannels(result.data); );
} catch { setChannels(result.data);
// ignore } catch {
} finally { // ignore
setLoading(false); } finally {
} setLoading(false);
}, [guildId]); }
},
[guildId],
);
useEffect(() => { useEffect(() => {
fetchChannels(); fetchChannels();
@@ -450,8 +456,8 @@ function ChannelsView({
{loading ? ( {loading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }, (_, i) => `ch-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="h-4 w-32 bg-muted rounded animate-pulse" /> <div className="h-4 w-32 bg-muted rounded animate-pulse" />
<div className="h-3 w-24 bg-muted rounded animate-pulse" /> <div className="h-3 w-24 bg-muted rounded animate-pulse" />
</div> </div>
@@ -517,9 +523,11 @@ function UserDetailView({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden"> <div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden">
{user.avatar_url ? ( {user.avatar_url ? (
<img <Image
src={user.avatar_url} src={user.avatar_url}
alt="" alt=""
width={64}
height={64}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -541,7 +549,10 @@ function UserDetailView({
value={user.flagged_count} value={user.flagged_count}
variant="destructive" variant="destructive"
/> />
<DetailStat label="Clean Streak" value={user.clean_message_streak} /> <DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat <DetailStat
label="Trust Score" label="Trust Score"
value={user.trust_score ?? 0} value={user.trust_score ?? 0}
@@ -14,6 +14,7 @@ import {
Trash2, Trash2,
Volume2, Volume2,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { recordingsApi, voiceApi } from "@/lib/api"; import { recordingsApi, voiceApi } from "@/lib/api";
import type { import type {
@@ -104,7 +105,7 @@ export function LivePanel() {
const unsubSpeaker = ws.on("voice_active_user", (user) => { const unsubSpeaker = ws.on("voice_active_user", (user) => {
const speaker = user as ActiveSpeaker; const speaker = user as ActiveSpeaker;
setSpeakers((prev) => { setSpeakers((prev) => {
const existing = prev.findIndex((s) => s.user_id === speaker.user_id); const existing = prev.findIndex((s) => s.userId === speaker.userId);
if (existing >= 0) { if (existing >= 0) {
const next = [...prev]; const next = [...prev];
next[existing] = speaker; next[existing] = speaker;
@@ -307,7 +308,7 @@ export function LivePanel() {
.filter((s) => s.speaking) .filter((s) => s.speaking)
.map((s) => ( .map((s) => (
<div <div
key={s.user_id} key={s.userId}
className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5" className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5"
> >
<span className="relative flex size-2"> <span className="relative flex size-2">
@@ -354,9 +355,11 @@ export function LivePanel() {
<p className="text-xs text-muted-foreground">Now Playing</p> <p className="text-xs text-muted-foreground">Now Playing</p>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{mediaState.current.thumbnailUrl && ( {mediaState.current.thumbnailUrl && (
<img <Image
src={mediaState.current.thumbnailUrl} src={mediaState.current.thumbnailUrl}
alt="" alt=""
width={48}
height={48}
className="size-12 rounded object-cover" className="size-12 rounded object-cover"
/> />
)} )}
@@ -120,9 +120,9 @@ export function MascotChatbot() {
Ask me anything about the server! Ask me anything about the server!
</p> </p>
)} )}
{messages.map((msg, i) => ( {messages.map((msg, _i) => (
<div <div
key={i} key={msg.timestamp + msg.role}
className={`flex items-start gap-2 ${ className={`flex items-start gap-2 ${
msg.role === "user" ? "flex-row-reverse" : "" msg.role === "user" ? "flex-row-reverse" : ""
}`} }`}
@@ -9,6 +9,7 @@ import {
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { messagesApi, voiceApi } from "@/lib/api"; import { messagesApi, voiceApi } from "@/lib/api";
import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types";
@@ -26,7 +27,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
const [searchResults, setSearchResults] = useState<MessageRecord[] | null>( const [searchResults, setSearchResults] = useState<MessageRecord[] | null>(
null, null,
); );
const [searching, setSearching] = useState(false); const [_searching, setSearching] = useState(false);
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
const [imageMessages, setImageMessages] = useState<MessageRecord[]>([]); const [imageMessages, setImageMessages] = useState<MessageRecord[]>([]);
const [reviewMessages, setReviewMessages] = useState<MessageRecord[]>([]); const [reviewMessages, setReviewMessages] = useState<MessageRecord[]>([]);
@@ -42,22 +43,11 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
const ws = useWebSocket(); const ws = useWebSocket();
// ── Guild placeholder (after all hooks) ─────────────────── // ── Data-fetching side effects (all hooks before any early return) ──
if (!guildId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle className="size-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No guild selected. Select a guild above to view messages.
</p>
</div>
);
}
// ── Data-fetching side effects (guildId guaranteed non-empty) ──
// Fetch available text channels for filtering // Fetch available text channels for filtering
useEffect(() => { useEffect(() => {
if (!guildId) return;
voiceApi voiceApi
.getTextChannels(guildId) .getTextChannels(guildId)
.then(setChannels) .then(setChannels)
@@ -66,6 +56,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
// Fetch initial messages // Fetch initial messages
const fetchMessages = useCallback(async () => { const fetchMessages = useCallback(async () => {
if (!guildId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -86,6 +77,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
// Fetch image messages // Fetch image messages
const fetchImages = useCallback(async () => { const fetchImages = useCallback(async () => {
if (!guildId) return;
try { try {
const result = await messagesApi.getImages(guildId, 50); const result = await messagesApi.getImages(guildId, 50);
setImageMessages(result.data); setImageMessages(result.data);
@@ -118,6 +110,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
// WS subscription for real-time message updates // WS subscription for real-time message updates
useEffect(() => { useEffect(() => {
if (!guildId) return;
const unsubCreated = ws.on("message_created", (msg) => { const unsubCreated = ws.on("message_created", (msg) => {
setMessages((prev) => [msg as MessageRecord, ...prev]); setMessages((prev) => [msg as MessageRecord, ...prev]);
}); });
@@ -147,7 +140,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
unsubDeleted(); unsubDeleted();
unsubAnalyzed(); unsubAnalyzed();
}; };
}, [ws]); }, [ws, guildId]);
// Search handler // Search handler
const handleSearch = useCallback(async () => { const handleSearch = useCallback(async () => {
@@ -326,8 +319,8 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
<div className="space-y-2"> <div className="space-y-2">
{loading ? ( {loading ? (
<div className="space-y-3"> <div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }, (_, i) => `msg-sk-${i}`).map((key) => (
<div key={i} className="flex gap-3 rounded-lg border p-4"> <div key={key} className="flex gap-3 rounded-lg border p-4">
<div className="size-8 shrink-0 rounded-full bg-muted animate-pulse" /> <div className="size-8 shrink-0 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-4 w-32 bg-muted rounded animate-pulse" /> <div className="h-4 w-32 bg-muted rounded animate-pulse" />
@@ -441,9 +434,11 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden"> <div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
{detailMessage.avatar_url ? ( {detailMessage.avatar_url ? (
<img <Image
src={detailMessage.avatar_url} src={detailMessage.avatar_url}
alt="" alt=""
width={40}
height={40}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -649,9 +644,11 @@ function MessageCard({
{/* Avatar */} {/* Avatar */}
<div className="size-8 shrink-0 rounded-full bg-muted flex items-center justify-center text-xs font-medium overflow-hidden"> <div className="size-8 shrink-0 rounded-full bg-muted flex items-center justify-center text-xs font-medium overflow-hidden">
{msg.avatar_url ? ( {msg.avatar_url ? (
<img <Image
src={msg.avatar_url} src={msg.avatar_url}
alt="" alt=""
width={32}
height={32}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -735,7 +732,7 @@ function MessageCard({
<div <div
className="h-full rounded-full bg-primary" className="h-full rounded-full bg-primary"
style={{ style={{
width: msg.ai_confidence * 100 + "%", width: `${msg.ai_confidence * 100}%`,
}} }}
/> />
</div> </div>
@@ -26,6 +26,7 @@ export const dashboardApi = {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (limit) params.set("limit", String(limit)); if (limit) params.set("limit", String(limit));
if (search) params.set("search", search); if (search) params.set("search", search);
// Backend reads req.query.guild_id (snake_case) — see createDashboardRouter in dashboard.routes.ts
if (guildId) params.set("guild_id", guildId); if (guildId) params.set("guild_id", guildId);
const qs = params.toString(); const qs = params.toString();
return api.get<PaginatedChannels>( return api.get<PaginatedChannels>(
@@ -8,6 +8,7 @@ export const messagesApi = {
channelId?: string, channelId?: string,
cursor?: string, cursor?: string,
) => { ) => {
// Backend messageQuerySchema expects camelCase guildId (see messages.schema.ts)
const params = new URLSearchParams({ guildId }); const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit)); if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId); if (channelId) params.set("channelId", channelId);
@@ -31,6 +32,7 @@ export const messagesApi = {
api.get<MessageRecord>(`/api/messages/detail/${id}`), api.get<MessageRecord>(`/api/messages/detail/${id}`),
getImages: (guildId: string, limit?: number) => { getImages: (guildId: string, limit?: number) => {
// Backend reads req.query.guildId (camelCase) — see handleGetImageMessages in messages.controller.ts
const params = new URLSearchParams({ guildId }); const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit)); if (limit) params.set("limit", String(limit));
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>( return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
+9
View File
@@ -0,0 +1,9 @@
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
export const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
export type TabId = (typeof tabs)[number]["id"];
+3 -3
View File
@@ -37,13 +37,13 @@ export interface DashboardUser {
flagged_count: number; flagged_count: number;
last_message_at?: number | null; last_message_at?: number | null;
trust_score?: number | null; trust_score?: number | null;
clean_message_streak?: number; clean_message_streak?: number | null;
} }
export interface DashboardUserDetail extends DashboardUser { export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at?: number | null; last_analyzed_at?: number | null;
clean_message_streak: number; clean_message_streak: number | null;
total_infractions: number; total_infractions: number | null;
clean_count: number; clean_count: number;
recent_messages: MessageRecord[]; recent_messages: MessageRecord[];
} }
+2 -3
View File
@@ -1,14 +1,13 @@
export interface Guild { export interface Guild {
id: string; id: string;
name: string; name: string;
icon?: string | null; icon: string | null;
} }
export interface Channel { export interface Channel {
id: string; id: string;
name: string; name: string;
type?: string | null; // "voice" | "text" type: "voice" | "text";
parent_id?: string | null;
} }
/** Shape of the /api/config response (camelCase keys from backend). */ /** Shape of the /api/config response (camelCase keys from backend). */
+2 -2
View File
@@ -3,7 +3,7 @@ export type MediaMode = "music" | "screen";
export interface MediaItem { export interface MediaItem {
id?: string | null; id?: string | null;
source: string; source: string;
title?: string | null; title: string;
mode?: MediaMode | null; mode?: MediaMode | null;
durationMs?: number | null; durationMs?: number | null;
thumbnailUrl?: string | null; thumbnailUrl?: string | null;
@@ -12,6 +12,6 @@ export interface MediaItem {
export interface MediaState { export interface MediaState {
playing: boolean; playing: boolean;
musicVolume: number; musicVolume: number;
current?: MediaItem | null; current: MediaItem | null;
queue: MediaItem[]; queue: MediaItem[];
} }
+3 -1
View File
@@ -82,12 +82,14 @@ export interface MessageRecord {
channel_id: string; channel_id: string;
thread_id?: string | null; thread_id?: string | null;
reference_message_id?: string | null; reference_message_id?: string | null;
reference_channel_id?: string | null;
reference_guild_id?: string | null;
user_id: string; user_id: string;
username: string; username: string;
avatar_url?: string | null; avatar_url?: string | null;
content: string; content: string;
edited_content?: string | null; edited_content?: string | null;
type: string; // "text" | "edited" | "deleted" type: "text" | "edited" | "deleted";
is_reply?: boolean | null; is_reply?: boolean | null;
is_forward?: boolean | null; is_forward?: boolean | null;
is_crosspost?: boolean | null; is_crosspost?: boolean | null;
+1 -1
View File
@@ -8,10 +8,10 @@ export interface VoiceRecording {
channel_name?: string | null; channel_name?: string | null;
filename: string; filename: string;
size_bytes: number; size_bytes: number;
duration_bytes: number;
download_url?: string | null; download_url?: string | null;
upload_status: string; upload_status: string;
upload_error?: string | null; upload_error?: string | null;
transcription?: string | null;
created_at: number; created_at: number;
uploaded_at?: number | null; uploaded_at?: number | null;
} }
+1 -2
View File
@@ -14,8 +14,7 @@ export interface VoiceStatus {
} }
export interface ActiveSpeaker { export interface ActiveSpeaker {
id?: string | null; userId: string;
user_id: string;
username: string; username: string;
avatar?: string | null; avatar?: string | null;
speaking: boolean; speaking: boolean;
+2 -1
View File
@@ -36,7 +36,8 @@ export interface WsEventMap {
voice_recording_stopped: unknown; voice_recording_stopped: unknown;
voice_recording_uploaded: VoiceRecording; voice_recording_uploaded: VoiceRecording;
voice_active_user: ActiveSpeaker; voice_active_user: ActiveSpeaker;
voice_pcm_data: { userId: string; pcm: string }; /** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
voice_pcm_data: never;
voice_analyzed: unknown; voice_analyzed: unknown;
analysis_queue_status: unknown; analysis_queue_status: unknown;
reaction_added: unknown; reaction_added: unknown;