Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e6f3b47a | ||
|
|
dbcf9d68f2 | ||
|
|
ef4281cd1f | ||
|
|
25f6609a9f | ||
|
|
3f199aa70d | ||
|
|
a82265f4a9 |
+1
-1
@@ -39,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
|||||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||||
|
|
||||||
# === Webserver ===
|
# === Webserver ===
|
||||||
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001)
|
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||||
|
|
||||||
# === Connection ===
|
# === Connection ===
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
# Bete — Discord Moderation Dashboard
|
|
||||||
|
|
||||||
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis AI opsional, dan menyediakan dashboard web real-time.
|
|
||||||
|
|
||||||
**Stack utama:** Node.js (Express 5), pnpm, TypeScript, React 19 (Next.js 16), Tailwind v4, shadcn/ui, Drizzle ORM, PostgreSQL, WebSocket, Redis pub/sub.
|
|
||||||
|
|
||||||
## Prasyarat
|
|
||||||
|
|
||||||
- Node.js 22+
|
|
||||||
- pnpm 11.x
|
|
||||||
- FFmpeg di `PATH` (untuk audio muxing dan playback media)
|
|
||||||
- `yt-dlp` di `PATH` (untuk resolve audio YouTube/Spotify)
|
|
||||||
- Bun (untuk frontend dev — opsional, bisa pake pnpm)
|
|
||||||
- PostgreSQL 15+
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env sesuai konfigurasi server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Menjalankan
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend (port 3001)
|
|
||||||
pnpm run dev:backend
|
|
||||||
|
|
||||||
# Discord Gateway (capture messages, voice, dll)
|
|
||||||
pnpm run dev:discord-gateway
|
|
||||||
|
|
||||||
# Frontend (port 3000)
|
|
||||||
pnpm run dev:web
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run build:backend
|
|
||||||
pnpm run build:discord-gateway
|
|
||||||
pnpm run build:web # next build — static export ke out/
|
|
||||||
pnpm run build # build semua service
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh # Build + deploy semua service ke VPS
|
|
||||||
./deploy.sh --frontend # Frontend only
|
|
||||||
./deploy.sh --backend # Backend only
|
|
||||||
./deploy.sh --no-build # Skip build, copy files aja
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Discord
|
|
||||||
|
|
|
||||||
v
|
|
||||||
discord-gateway ←→ Redis ←→ backend (Express 5) ←→ frontend (Next.js)
|
|
||||||
| pub/sub | |
|
|
||||||
| +— REST API (/api/*) |
|
|
||||||
| +— WebSocket (/ws) |
|
|
||||||
+— message capture +— AI moderation |
|
|
||||||
+— voice recording +— dashboard data +— dashboard UI
|
|
||||||
+— attachment upload +— real-time updates
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fitur
|
|
||||||
|
|
||||||
- **Message capture**: Capture pesan baru, edit, dan delete dari Discord
|
|
||||||
- **Voice recording**: Rekam voice channel ke segmen OGG per user, streaming PCM real-time ke WebSocket
|
|
||||||
- **Attachment upload**: Download + upload attachment ke external storage
|
|
||||||
- **AI moderation**: Analisis pesan opsional via LLM, auto-delete, queue management
|
|
||||||
- **Dashboard**: Messages feed, AI analysis review, voice connection, music player, recordings, user/channel stats
|
|
||||||
- **Media playback**: Playback dari URL, file lokal, YouTube, Spotify
|
|
||||||
- **WebSocket**: Real-time event streaming untuk semua aktivitas
|
|
||||||
- **Public API**: Semua endpoint REST dan WebSocket dapat diakses tanpa autentikasi
|
|
||||||
|
|
||||||
## Struktur Proyek
|
|
||||||
|
|
||||||
```
|
|
||||||
services/
|
|
||||||
├── backend/ # Express 5 REST API + WebSocket server
|
|
||||||
│ ├── src/modules/ # Feature modules (messages, voice, media, dll)
|
|
||||||
│ └── src/http/ # Express app setup, middleware
|
|
||||||
├── discord-gateway/ # Discord client, voice recording, AI analysis
|
|
||||||
│ ├── src/modules/ # message-capture, voice-recording, ai-moderation
|
|
||||||
│ └── src/shared/ # Config, database, Discord client
|
|
||||||
└── frontend/ # Next.js 16 dashboard (static export)
|
|
||||||
├── src/app/ # Pages (login, dashboard tabs)
|
|
||||||
├── src/features/ # Feature components (dashboard, live, messages)
|
|
||||||
└── src/lib/ # API client, WebSocket, types
|
|
||||||
packages/
|
|
||||||
└── shared/ # Shared types, errors, logger, utilities
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
PostgreSQL via Drizzle ORM. Migrasi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run db:generate # Generate migration
|
|
||||||
pnpm run db:migrate # Apply migration
|
|
||||||
pnpm run db:studio # Drizzle Studio
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebSocket Events
|
|
||||||
|
|
||||||
Backend broadcast event berikut ke frontend via WebSocket:
|
|
||||||
|
|
||||||
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
|
|
||||||
- `attachment_created`, `attachment_uploaded`
|
|
||||||
- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded`
|
|
||||||
- `voice_active_user`, `voice_pcm_data`
|
|
||||||
- `media_state`
|
|
||||||
- `reaction_*`, `thread_*`, `presence_updated`, `guild_member_*`
|
|
||||||
@@ -33,9 +33,9 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
WEBSERVER_PORT: 3000
|
WEBSERVER_PORT: 4000
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* E2E API tests — runs against a running backend instance.
|
* E2E API tests — runs against a running backend instance.
|
||||||
* Usage: API_BASE=http://localhost:3001 vitest run
|
* Usage: API_BASE=http://localhost:4001 vitest run
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const BASE = process.env.API_BASE ?? "http://localhost:3001/api";
|
const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
|
||||||
|
|
||||||
async function api(path: string, init?: RequestInit) {
|
async function api(path: string, init?: RequestInit) {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface RecordingRow {
|
|||||||
upload_error: string | null;
|
upload_error: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at: number | null;
|
uploaded_at: number | null;
|
||||||
duration_bytes: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedRecordings {
|
export interface PaginatedRecordings {
|
||||||
@@ -69,7 +68,6 @@ export class RecordingsService {
|
|||||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||||
created_at: pgVoiceRecordingsTable.created_at,
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||||
duration_bytes: pgVoiceRecordingsTable.size_bytes,
|
|
||||||
})
|
})
|
||||||
.from(pgVoiceRecordingsTable)
|
.from(pgVoiceRecordingsTable)
|
||||||
.where(where)
|
.where(where)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createHandlerRegistry,
|
createHandlerRegistry,
|
||||||
} from "./handler-registry.js";
|
} from "./handler-registry.js";
|
||||||
import { MediaHandler } from "./media.handler.js";
|
import { MediaHandler } from "./media.handler.js";
|
||||||
|
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
|
||||||
import { ModerationHandler } from "./moderation.handler.js";
|
import { ModerationHandler } from "./moderation.handler.js";
|
||||||
import { VoiceHandler } from "./voice.handler.js";
|
import { VoiceHandler } from "./voice.handler.js";
|
||||||
|
|
||||||
@@ -88,6 +89,10 @@ export class CommandHandler {
|
|||||||
this.guildHandler = new GuildHandler(client);
|
this.guildHandler = new GuildHandler(client);
|
||||||
this.moderationHandler = new ModerationHandler(client);
|
this.moderationHandler = new ModerationHandler(client);
|
||||||
|
|
||||||
|
// Wire the media status sink so MediaHandler can persist status on
|
||||||
|
// queue advances that happen outside a command (natural track end).
|
||||||
|
wireMediaStatusWriter(this.redisPub);
|
||||||
|
|
||||||
// Build the command registry
|
// Build the command registry
|
||||||
this.registry = createHandlerRegistry(
|
this.registry = createHandlerRegistry(
|
||||||
this.voiceHandler,
|
this.voiceHandler,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ScreenShareController,
|
ScreenShareController,
|
||||||
type ScreenShareVoiceStatus,
|
type ScreenShareVoiceStatus,
|
||||||
} from "../voice-recording/screenShareController.js";
|
} from "../voice-recording/screenShareController.js";
|
||||||
|
import { setMediaStatusKey } from "./mediaStatusSink.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -88,14 +89,36 @@ export class MediaHandler {
|
|||||||
activeChannelId: null,
|
activeChannelId: null,
|
||||||
}),
|
}),
|
||||||
) {
|
) {
|
||||||
// Register auto-advance on natural track end
|
// Register auto-advance on natural track end. advanceQueue mutates the
|
||||||
|
// module-level currentTrackItem/queue, so we must re-publish the status
|
||||||
|
// key afterward: otherwise the backend's Redis `media:status` cache (and
|
||||||
|
// the frontend's 10s polling) stays stuck on the finished track.
|
||||||
discordPlayer.onIdle(() => {
|
discordPlayer.onIdle(() => {
|
||||||
this.advanceQueue().catch((err) => {
|
this.advanceQueue()
|
||||||
this.logger.error({ err }, "Auto-advance failed");
|
.then(() => this.publishStatus())
|
||||||
});
|
.catch((err) => {
|
||||||
|
this.logger.error({ err }, "Auto-advance failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the latest media state to Redis so the backend/frontend see queue
|
||||||
|
* advances that happen outside a command (natural track end, screen-share
|
||||||
|
* done). CommandHandler owns the Redis status-key writes for command-triggered
|
||||||
|
* changes; this covers the side-effect-only path.
|
||||||
|
*/
|
||||||
|
private publishStatus(): void {
|
||||||
|
try {
|
||||||
|
setMediaStatusKey(this.getCurrentMediaStatus());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ error: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Failed to publish media status on track end",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCurrentMediaStatus(): MediaStatusPayload {
|
getCurrentMediaStatus(): MediaStatusPayload {
|
||||||
return buildStatusPayload();
|
return buildStatusPayload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type Redis from "ioredis";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { MEDIA_STATUS_KEY } from "../../shared/redis-channels.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared sink for writing the media status Redis key.
|
||||||
|
*
|
||||||
|
* CommandHandler owns the publisher + status writes for command-triggered
|
||||||
|
* changes (`publishMediaStatus`). MediaHandler needs to also persist status
|
||||||
|
* when the queue advances *outside* a command (natural track end / screen-share
|
||||||
|
* done), so we expose the real publisher here and let CommandHandler wire it
|
||||||
|
* once at startup.
|
||||||
|
*/
|
||||||
|
const logger = createChildLogger("media-status-sink");
|
||||||
|
|
||||||
|
let _setMediaStatusKey: ((payload: unknown) => void) | null = null;
|
||||||
|
|
||||||
|
export function setMediaStatusWriter(writer: (payload: unknown) => void): void {
|
||||||
|
_setMediaStatusKey = writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaStatusKey(payload: unknown): void {
|
||||||
|
if (!_setMediaStatusKey) {
|
||||||
|
logger.warn("Media status writer not wired — skipping status publish");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setMediaStatusKey(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MEDIA_STATUS_KEY };
|
||||||
|
|
||||||
|
export function wireMediaStatusWriter(redisPub: Redis): void {
|
||||||
|
setMediaStatusWriter((payload) => {
|
||||||
|
redisPub
|
||||||
|
.set(MEDIA_STATUS_KEY, JSON.stringify(payload))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.warn({ error: msg }, "Failed to update media status key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -343,6 +343,20 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
id: newMessage.id,
|
id: newMessage.id,
|
||||||
edited_content: getDisplayContent(newMessage as Message),
|
edited_content: getDisplayContent(newMessage as Message),
|
||||||
edited_at: editedAt,
|
edited_at: editedAt,
|
||||||
|
type: "edited",
|
||||||
|
// Match the DB update (updateMessageAsEdited resets analysis to
|
||||||
|
// pending) so the live UI reflects the same state instead of
|
||||||
|
// lingering on the stale pre-edit verdict.
|
||||||
|
ai_status: "pending",
|
||||||
|
ai_moderation_flags: null,
|
||||||
|
ai_moderation_score: null,
|
||||||
|
ai_analysis: null,
|
||||||
|
ai_categories: null,
|
||||||
|
ai_severity: null,
|
||||||
|
ai_confidence: null,
|
||||||
|
ai_recommended_action: null,
|
||||||
|
ai_analyzed_at: null,
|
||||||
|
ai_error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (newMessage.author) {
|
} else if (newMessage.author) {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export default function VoicePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "activity" && <VoiceActivityTimeline />}
|
{tab === "activity" && <VoiceActivityTimeline data={speakers} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ export function SearchPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed">
|
<p className="text-sm leading-relaxed">
|
||||||
{renderMessageContent(msg.content, msg.metadata)}
|
{renderMessageContent(
|
||||||
|
msg.edited_content ?? msg.content,
|
||||||
|
msg.metadata,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{msg.ai_moderation_flags &&
|
{msg.ai_moderation_flags &&
|
||||||
msg.ai_moderation_flags !== "[]" && (
|
msg.ai_moderation_flags !== "[]" && (
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function MessageCard({
|
|||||||
deleted
|
deleted
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{msg.type === "edited" && (
|
{(msg.type === "edited" || msg.edited_content) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
className="text-[10px] px-1.5 py-0 h-4"
|
||||||
@@ -102,7 +102,10 @@ export function MessageCard({
|
|||||||
"italic text-muted-foreground line-through",
|
"italic text-muted-foreground line-through",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(msg.content, msg.metadata)}
|
{renderMessageContent(
|
||||||
|
msg.edited_content ?? msg.content,
|
||||||
|
msg.metadata,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const u = extractFirstImage(msg.metadata);
|
const u = extractFirstImage(msg.metadata);
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export function MessageDetailView({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||||
{renderMessageContent(message.content, message.metadata) ||
|
{renderMessageContent(
|
||||||
"(no text content)"}
|
message.edited_content ?? message.content,
|
||||||
|
message.metadata,
|
||||||
|
) || "(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export function MessageDetail({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||||
{renderMessageContent(message.content, message.metadata) ||
|
{renderMessageContent(
|
||||||
"(no text content)"}
|
message.edited_content ?? message.content,
|
||||||
|
message.metadata,
|
||||||
|
) || "(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Download, Loader2, Pause, Play } from "lucide-react";
|
import { Download, Loader2, Pause, Play } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
|
||||||
interface RecordingCardProps {
|
interface RecordingCardProps {
|
||||||
@@ -24,9 +25,9 @@ export function RecordingCard({
|
|||||||
onTogglePlay,
|
onTogglePlay,
|
||||||
}: RecordingCardProps) {
|
}: RecordingCardProps) {
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const durationStr = recording.duration_bytes
|
const sizeStr = recording.size_bytes
|
||||||
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
|
? formatBytes(recording.size_bytes)
|
||||||
: "--:--";
|
: "--";
|
||||||
|
|
||||||
// Fetch the file (CORS is open on the uploader) → blob → force download with
|
// Fetch the file (CORS is open on the uploader) → blob → force download with
|
||||||
// the real filename. Falls back to opening the URL in a new tab.
|
// the real filename. Falls back to opening the URL in a new tab.
|
||||||
@@ -119,7 +120,7 @@ export function RecordingCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-mono text-text-secondary/60">
|
<span className="text-[10px] font-mono text-text-secondary/60">
|
||||||
{durationStr}
|
{sizeStr}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-text-secondary/40">
|
<span className="text-[10px] text-text-secondary/40">
|
||||||
{new Date(recording.created_at).toLocaleString()}
|
{new Date(recording.created_at).toLocaleString()}
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Mic, MicOff } from "lucide-react";
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
import { useMounted } from "@/lib/hooks/use-mounted";
|
import type { ActiveSpeaker } from "@/lib/types";
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
data?: { user: string; duration: number }[];
|
data?: ActiveSpeaker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice Activity — live view of everyone currently in the monitored voice
|
||||||
|
* channel and whether they are speaking right now.
|
||||||
|
*
|
||||||
|
* Previously this rendered a Recharts bar chart fed from a `{user, duration}`
|
||||||
|
* prop that NO caller ever supplied, so the Activity tab always showed an
|
||||||
|
* empty, misleading chart. It now renders real live speaker state from the
|
||||||
|
* WebSocket (same source as the Connection tab's waveform).
|
||||||
|
*/
|
||||||
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||||
const mounted = useMounted();
|
const sorted = [...data].sort((a, b) =>
|
||||||
|
a.speaking === b.speaking
|
||||||
|
? String(a.username).localeCompare(b.username)
|
||||||
|
: a.speaking
|
||||||
|
? -1
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
@@ -24,54 +32,53 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
|||||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||||
Voice Activity
|
Voice Activity
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/50 ml-auto">
|
||||||
|
{sorted.length} speaker{sorted.length !== 1 ? "s" : ""} · live
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-40">
|
|
||||||
{mounted ? (
|
{sorted.length === 0 ? (
|
||||||
<ResponsiveContainer
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
width="100%"
|
<MicOff className="size-8 text-text-secondary/30 mb-2" />
|
||||||
height={160}
|
<p className="text-xs text-text-secondary/60">
|
||||||
minWidth={0}
|
No speakers in the monitored voice channel.
|
||||||
minHeight={0}
|
</p>
|
||||||
>
|
<p className="mt-1 text-[10px] text-text-secondary/40">
|
||||||
<BarChart data={data} layout="vertical">
|
Connect to a voice channel to see live activity here.
|
||||||
<XAxis
|
</p>
|
||||||
type="number"
|
</div>
|
||||||
axisLine={false}
|
) : (
|
||||||
tickLine={false}
|
<div className="space-y-1.5">
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
{sorted.map((s) => (
|
||||||
/>
|
<div
|
||||||
<YAxis
|
key={s.userId}
|
||||||
type="category"
|
className="flex items-center gap-2 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
|
||||||
dataKey="user"
|
>
|
||||||
axisLine={false}
|
{s.speaking ? (
|
||||||
tickLine={false}
|
<Mic className="size-3.5 text-primary shrink-0" />
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
) : (
|
||||||
width={80}
|
<MicOff className="size-3.5 text-text-secondary/40 shrink-0" />
|
||||||
/>
|
)}
|
||||||
<Tooltip
|
<span
|
||||||
contentStyle={{
|
className={`truncate text-sm ${
|
||||||
background: "oklch(0.11 0.02 245 / 0.9)",
|
s.speaking
|
||||||
border: "1px solid oklch(1 0 0 / 0.08)",
|
? "text-text-primary font-medium"
|
||||||
borderRadius: 8,
|
: "text-text-secondary/70"
|
||||||
fontSize: 12,
|
}`}
|
||||||
color: "oklch(0.93 0.01 245)",
|
>
|
||||||
}}
|
{s.username}
|
||||||
formatter={(value) => [
|
</span>
|
||||||
`${(Number(value) / 60).toFixed(1)}m`,
|
<span
|
||||||
"Duration",
|
className={`ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-widest ${
|
||||||
]}
|
s.speaking ? "text-primary" : "text-text-secondary/40"
|
||||||
/>
|
}`}
|
||||||
<Bar
|
>
|
||||||
dataKey="duration"
|
{s.speaking ? "Speaking" : "Listening"}
|
||||||
fill="var(--color-primary)"
|
</span>
|
||||||
radius={[0, 4, 4, 0]}
|
</div>
|
||||||
/>
|
))}
|
||||||
</BarChart>
|
</div>
|
||||||
</ResponsiveContainer>
|
)}
|
||||||
) : (
|
|
||||||
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,43 +183,87 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!guildId) return;
|
if (!guildId) return;
|
||||||
// Patch every message-list key for this guild (all channels + "__all__")
|
// Patch every message-list key for this guild (all channels + "**filtered**").
|
||||||
|
// The updater receives the SWR key so we can honor its channel filter:
|
||||||
|
// a live `message_created`/updated for channel B must NOT be prepended to
|
||||||
|
// a list that is filtered down to channel A.
|
||||||
const patchLists = (
|
const patchLists = (
|
||||||
|
matcher: (key: unknown, msg: { channel_id?: string }) => boolean,
|
||||||
updater: (old: MessagePage | undefined) => MessagePage | undefined,
|
updater: (old: MessagePage | undefined) => MessagePage | undefined,
|
||||||
|
msg: { channel_id?: string },
|
||||||
) => {
|
) => {
|
||||||
void mutate(
|
void mutate(
|
||||||
(key) =>
|
(key) =>
|
||||||
Array.isArray(key) && key[0] === "messages" && key[1] === guildId,
|
Array.isArray(key) &&
|
||||||
|
key[0] === "messages" &&
|
||||||
|
key[1] === guildId &&
|
||||||
|
matcher(key, msg),
|
||||||
updater,
|
updater,
|
||||||
{ revalidate: false },
|
{ revalidate: false },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// A list key [messages, guildId, channelId] is "channel N" when channelId
|
||||||
|
// is a non-empty string and matches the incoming message; "__all__" (or
|
||||||
|
// any non-channel) lists accept every message of the guild.
|
||||||
|
const matchesFilter = (key: unknown[], msg: { channel_id?: string }) => {
|
||||||
|
const channelId = key[2] as string | undefined;
|
||||||
|
if (!channelId || channelId === "__all__") return true;
|
||||||
|
return msg.channel_id === channelId;
|
||||||
|
};
|
||||||
|
|
||||||
const unsub1 = ws.on("message_created", (data) => {
|
const unsub1 = ws.on("message_created", (data) => {
|
||||||
const msg = data as MessageRecord;
|
const msg = data as MessageRecord;
|
||||||
patchLists((old) => (old ? { ...old, data: [msg, ...old.data] } : old));
|
patchLists(
|
||||||
|
(_k, m) => matchesFilter(_k as unknown[], m),
|
||||||
|
(old) => (old ? { ...old, data: [msg, ...old.data] } : old),
|
||||||
|
msg,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const unsub2 = ws.on("message_updated", (data) => {
|
const unsub2 = ws.on("message_updated", (data) => {
|
||||||
const msg = data as MessageRecord;
|
const msg = data as Partial<MessageRecord> & { id: string };
|
||||||
patchLists((old) =>
|
// The gateway broadcasts a PARTIAL update ({ id, edited_content,
|
||||||
old
|
// edited_at, ... }) — merge it over the existing record instead of
|
||||||
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
|
// replacing it, or the card would lose username/content/channel/etc.
|
||||||
: old,
|
patchLists(
|
||||||
|
(_k, m) =>
|
||||||
|
(m as Partial<MessageRecord>).channel_id === undefined ||
|
||||||
|
matchesFilter(_k as unknown[], m),
|
||||||
|
(old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
data: old.data.map((m) =>
|
||||||
|
m.id === msg.id ? { ...m, ...msg } : m,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
void mutate(
|
||||||
|
msgKeys.detail(msg.id),
|
||||||
|
(old: MessageRecord | undefined) => (old ? { ...old, ...msg } : old),
|
||||||
|
{ revalidate: false },
|
||||||
);
|
);
|
||||||
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
|
|
||||||
});
|
});
|
||||||
const unsub3 = ws.on("message_deleted", (data) => {
|
const unsub3 = ws.on("message_deleted", (data) => {
|
||||||
const { id } = data as { id: string };
|
const { id } = data as { id: string };
|
||||||
patchLists((old) =>
|
patchLists(
|
||||||
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old,
|
() => true,
|
||||||
|
(old) =>
|
||||||
|
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old,
|
||||||
|
{ channel_id: undefined },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const unsub4 = ws.on("message_analyzed", (data) => {
|
const unsub4 = ws.on("message_analyzed", (data) => {
|
||||||
const msg = data as MessageRecord;
|
const msg = data as MessageRecord;
|
||||||
patchLists((old) =>
|
// message_analyzed carries the FULL record — replace is fine.
|
||||||
old
|
patchLists(
|
||||||
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
|
(_k, m) => matchesFilter(_k as unknown[], m),
|
||||||
: old,
|
(old) =>
|
||||||
|
old
|
||||||
|
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
|
||||||
|
: old,
|
||||||
|
msg,
|
||||||
);
|
);
|
||||||
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
|
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export interface VoiceRecording {
|
|||||||
channel_name?: string | null;
|
channel_name?: string | null;
|
||||||
filename: string;
|
filename: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
/** Present on REST rows; absent on WS voice_recording_uploaded events */
|
|
||||||
duration_bytes?: number | null;
|
|
||||||
download_url?: string | null;
|
download_url?: string | null;
|
||||||
upload_status: string;
|
upload_status: string;
|
||||||
upload_error?: string | null;
|
upload_error?: string | null;
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export interface WsBinaryEvent {
|
|||||||
|
|
||||||
export interface WsEventMap {
|
export interface WsEventMap {
|
||||||
message_created: MessageRecord;
|
message_created: MessageRecord;
|
||||||
message_updated: MessageRecord;
|
/**
|
||||||
|
* The gateway broadcasts a PARTIAL update: { id } plus the changed fields
|
||||||
|
* (edited_content, edited_at, type, and the reset ai_* fields). It is NOT a
|
||||||
|
* full MessageRecord — merge it, never rely on it carrying the full row.
|
||||||
|
*/
|
||||||
|
message_updated: Partial<MessageRecord> & { id: string };
|
||||||
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
||||||
message_deleted: { id: string; deleted_at?: number };
|
message_deleted: { id: string; deleted_at?: number };
|
||||||
message_analyzed: MessageRecord;
|
message_analyzed: MessageRecord;
|
||||||
|
|||||||
Reference in New Issue
Block a user