Compare commits
27 Commits
2744e7035b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 | ||
|
|
c0f66c78a3 | ||
|
|
b8a6f40b1b | ||
|
|
4931e6d1ca | ||
|
|
a3e6c4695a | ||
|
|
6de5342703 | ||
|
|
5a926dbd17 | ||
|
|
7985efbef6 | ||
|
|
71889ab689 | ||
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d | ||
|
|
9ad7d16a17 | ||
|
|
62d131cf14 | ||
|
|
82025a19b2 | ||
|
|
3c7d722973 | ||
|
|
70931576dc | ||
|
|
8e1f5adaa4 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
recordings
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
test_out.nut
|
||||||
30
.env.test
30
.env.test
@@ -1,30 +0,0 @@
|
|||||||
DISCORD_TOKEN=test_token_for_testing
|
|
||||||
RECORDINGS_DIR=./recordings
|
|
||||||
RECORDING_SEGMENT_MS=5000
|
|
||||||
VERBOSE=false
|
|
||||||
DECODER_ROTATE_MS=5000
|
|
||||||
DECODER_COOLDOWN_MS=30000
|
|
||||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000
|
|
||||||
PACKET_FILTER_MIN_SIZE=8
|
|
||||||
OPUS_FRAME_SIZE=960
|
|
||||||
AUDIO_SAMPLE_RATE=48000
|
|
||||||
AUDIO_CHANNELS=2
|
|
||||||
AVATAR_SIZE=64
|
|
||||||
WEBSERVER_PORT=3000
|
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
|
||||||
LOG_LEVEL=info
|
|
||||||
NODE_ENV=test
|
|
||||||
MONITOR_GUILD_ID=test_guild_id
|
|
||||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
|
||||||
ATTACHMENT_RETRY_ATTEMPTS=3
|
|
||||||
BACKLOG_SYNC_HOURS=24
|
|
||||||
BACKLOG_SYNC_BATCH_SIZE=100
|
|
||||||
AI_ANALYSIS_ENABLED=false
|
|
||||||
AI_LLM_API_KEY=test_key
|
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
|
||||||
AI_LLM_MODEL=free
|
|
||||||
AI_ANALYSIS_TIMEOUT_MS=30000
|
|
||||||
DATABASE_TYPE=sqlite
|
|
||||||
35
.github/workflows/deploy.yml
vendored
Normal file
35
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Deploy to VPS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ secrets.ENV_FILE }}
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VPS_HOST }}
|
||||||
|
username: ${{ secrets.VPS_USERNAME }}
|
||||||
|
key: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
envs: ENV_FILE
|
||||||
|
script: |
|
||||||
|
cd /opt/imphenbot || exit
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Write environment variables from GitHub Secrets
|
||||||
|
echo "$ENV_FILE" > .env
|
||||||
|
|
||||||
|
# Build and restart containers
|
||||||
|
docker-compose up -d --build
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
public/app/
|
public/app/
|
||||||
.muxer-queue.**
|
.muxer-queue.**
|
||||||
.claude/
|
.claude/
|
||||||
|
.env.test
|
||||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,6 +1,4 @@
|
|||||||
[submodule "vendor/discord.js-selfbot-v13"]
|
[submodule "vendor/discord.js-selfbot-v13"]
|
||||||
path = vendor/discord.js-selfbot-v13
|
path = vendor/discord.js-selfbot-v13
|
||||||
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
||||||
[submodule "vendor/Discord-video-stream"]
|
|
||||||
path = vendor/Discord-video-stream
|
|
||||||
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
|
|
||||||
|
|||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# Install dependencies required by node-canvas, ffmpeg, and yt-dlp
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
python3 \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install yt-dlp
|
||||||
|
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||||
|
chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first for better caching
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build step if required (e.g. build:web)
|
||||||
|
RUN pnpm run build:web || true
|
||||||
|
|
||||||
|
# Set node environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["pnpm", "run", "start"]
|
||||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "frontend/src/styles.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "frontend/src/components",
|
||||||
|
"utils": "frontend/src/lib/utils",
|
||||||
|
"ui": "frontend/src/components/ui",
|
||||||
|
"lib": "frontend/src/lib",
|
||||||
|
"hooks": "frontend/src/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
debug-screen.ts
Normal file
60
debug-screen.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { createYtDlp } from "./src/media/ytdlp.js";
|
||||||
|
import { prepareStream } from "./src/streaming/index.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const ytdlp = createYtDlp();
|
||||||
|
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
|
||||||
|
|
||||||
|
console.log("Getting direct video url...");
|
||||||
|
const directUrl = await ytdlp.getDirectVideoUrl(url);
|
||||||
|
console.log("Direct URL:", directUrl);
|
||||||
|
|
||||||
|
console.log("Preparing stream...");
|
||||||
|
const { command, output } = prepareStream(directUrl, {
|
||||||
|
logLevel: "debug",
|
||||||
|
customInputOptions: [
|
||||||
|
"-headers",
|
||||||
|
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ffmpeg = command as ChildProcess;
|
||||||
|
ffmpeg.stderr?.on("data", (data: Buffer) => {
|
||||||
|
console.log("FFMPEG STDERR:", data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
let bytesRead = 0;
|
||||||
|
output.on("data", (chunk: Buffer) => {
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
console.log("Stream bytes:", bytesRead);
|
||||||
|
if (bytesRead > 1024 * 1024) {
|
||||||
|
ffmpeg.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ffmpeg.on("exit", (code) => {
|
||||||
|
if (code === 0 || code === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
});
|
||||||
|
ffmpeg.on("error", reject);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(
|
||||||
|
"Debug stream failed:",
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
45
deploy.sh
Executable file
45
deploy.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration for CLI deployment
|
||||||
|
VPS_HOST="45.127.35.244"
|
||||||
|
VPS_USER="root"
|
||||||
|
# Find first available private key in ~/.ssh or use specific one if you want to hardcode
|
||||||
|
SSH_KEY_PATH=$(find ~/.ssh -name "id_rsa" -o -name "id_ed25519" | head -n 1)
|
||||||
|
|
||||||
|
echo "🚀 Starting CLI deployment to $VPS_USER@$VPS_HOST..."
|
||||||
|
|
||||||
|
if [ -z "$SSH_KEY_PATH" ]; then
|
||||||
|
echo "⚠️ No SSH key found in ~/.ssh. Falling back to default SSH behavior."
|
||||||
|
SSH_CMD="ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
|
||||||
|
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -o StrictHostKeyChecking=no'"
|
||||||
|
else
|
||||||
|
echo "🔑 Using SSH key: $SSH_KEY_PATH"
|
||||||
|
SSH_CMD="ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
|
||||||
|
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Directory on the VPS where the app will be deployed
|
||||||
|
REMOTE_DIR="/opt/imphenbot"
|
||||||
|
|
||||||
|
echo "📦 Syncing files to VPS..."
|
||||||
|
$SSH_CMD "mkdir -p $REMOTE_DIR"
|
||||||
|
eval "$RSYNC_CMD ./ $VPS_USER@$VPS_HOST:$REMOTE_DIR"
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "🔒 Copying local .env to VPS..."
|
||||||
|
if [ -z "$SSH_KEY_PATH" ]; then
|
||||||
|
scp -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
|
||||||
|
else
|
||||||
|
scp -i $SSH_KEY_PATH -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No local .env found to copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔄 Rebuilding and restarting Docker containers..."
|
||||||
|
$SSH_CMD << EOF
|
||||||
|
cd $REMOTE_DIR
|
||||||
|
docker-compose up -d --build
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: imphenbot-app
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./recordings:/app/recordings
|
||||||
|
# Mapping SQLite database files if needed, or storing them in a dedicated volume.
|
||||||
|
# Assuming default config uses root directory for DB.
|
||||||
|
- ./.muxer-queue.db:/app/.muxer-queue.db
|
||||||
|
- ./.muxer-queue.db-shm:/app/.muxer-queue.db-shm
|
||||||
|
- ./.muxer-queue.db-wal:/app/.muxer-queue.db-wal
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.tech`)"
|
||||||
|
- "traefik.http.routers.imphenbot.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.imphenbot.tls=true"
|
||||||
|
# Expose port to traefik (adjust if WEBSERVER_PORT differs)
|
||||||
|
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- app-shared-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
name: app-shared-net
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Internal Streamer Replacement Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Replace the external `@dank074/discord-video-stream` dependency with an internal streaming module that uses `discord.js-selfbot-v13` private APIs to deliver the same screen share behavior (video + audio) with identical UI/API surface.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Maintain feature parity for screen share (video + audio, 720p @ 30fps, bitrate 2500/4000, H264, audio on).
|
||||||
|
- Keep existing UI and API contracts unchanged (`/api/media/queue` with `mode: "screen"`).
|
||||||
|
- Remove `@dank074/discord-video-stream` from dependencies and delete `vendor/Discord-video-stream`.
|
||||||
|
- Ensure clean lifecycle handling (start/stop, cleanup, error reporting).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Rewriting WebRTC/RTP stack from scratch.
|
||||||
|
- Changing media queue behavior or UI layout.
|
||||||
|
- Adding new screen share modes or settings.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
Introduce a new internal module under `src/streaming/` that encapsulates:
|
||||||
|
- Voice/session management using private `discord.js-selfbot-v13` APIs.
|
||||||
|
- FFmpeg preparation for H264 + Opus (AnnexB video + Opus audio).
|
||||||
|
- Stream playback into the internal dispatcher.
|
||||||
|
|
||||||
|
`screenShareController` will depend on this module instead of `@dank074/discord-video-stream`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1) Streaming Session Module (`src/streaming/`)
|
||||||
|
Proposed exports:
|
||||||
|
- `createStreamSession(client)`
|
||||||
|
- Joins or reuses voice connection for video streaming.
|
||||||
|
- Exposes a `session` object with `startVideo()`, `stopVideo()`, and `sendStream(stream)` hooks.
|
||||||
|
- `prepareFfmpegStream(source, opts)`
|
||||||
|
- Spawns ffmpeg with the same parameters used today.
|
||||||
|
- Returns `{ command, output }` (output is a Readable stream).
|
||||||
|
- `playPreparedStream(output, session)`
|
||||||
|
- Pipes the prepared stream into the internal dispatcher.
|
||||||
|
- Returns a promise that resolves when playback completes.
|
||||||
|
|
||||||
|
### 2) Screen Share Controller (`src/media/screenShareController.ts`)
|
||||||
|
- Replace Streamer/prepareStream/playStream with internal module usage.
|
||||||
|
- Keep the public API identical (`start(source)` returning `ScreenSharePlayback`).
|
||||||
|
|
||||||
|
### 3) Web Server Wiring (`src/webserver.ts`)
|
||||||
|
- Remove `Streamer` instantiation and dependencies.
|
||||||
|
- Pass only `getVoiceStatus` and new streaming module dependencies into `createScreenShareController`.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. User queues screen share via `/api/media/queue` with `mode: "screen"`.
|
||||||
|
2. `MediaController` calls `screenShareController.start(source)`.
|
||||||
|
3. `screenShareController` resolves URL, calls `prepareFfmpegStream`.
|
||||||
|
4. `createStreamSession` ensures voice connection and dispatcher ready.
|
||||||
|
5. `playPreparedStream` sends output to Discord.
|
||||||
|
6. On completion or stop, cleanup runs and state updates propagate.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Voice not connected: throw `VOICE_NOT_CONNECTED`.
|
||||||
|
- FFmpeg spawn/exit failure: throw `SCREEN_STREAM_FAILED`.
|
||||||
|
- Dispatcher error: stop stream, cleanup, log error, set state idle.
|
||||||
|
|
||||||
|
## Lifecycle Rules
|
||||||
|
- `start()` always stops any active stream first.
|
||||||
|
- `stop()` kills ffmpeg, stops dispatcher, and resets internal state.
|
||||||
|
- Completion resolves `done` promise and triggers cleanup.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- Unit tests for `screenShareController`:
|
||||||
|
- Calls to `prepareFfmpegStream` and `playPreparedStream` on `start()`.
|
||||||
|
- Ensures `stop()` kills ffmpeg and ends session.
|
||||||
|
- Unit tests for `streaming` module:
|
||||||
|
- Session initialization and cleanup logic with mocked private APIs.
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
1. Implement `src/streaming/` module.
|
||||||
|
2. Update `screenShareController` to use internal module.
|
||||||
|
3. Remove `@dank074/discord-video-stream` imports and wiring.
|
||||||
|
4. Delete `vendor/Discord-video-stream` directory.
|
||||||
|
5. Update `package.json` dependencies.
|
||||||
|
6. Update tests.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Private `discord.js-selfbot-v13` APIs may change.
|
||||||
|
- Harder debugging if internal dispatcher behavior differs.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
- Revert to previous commit that restores `@dank074/discord-video-stream` and the vendor directory.
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Discord Moderation Dashboard</title>
|
<title>Discord Moderation Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,124 +1,259 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { listMessages, reanalyzeMessage } from "./api/client";
|
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
||||||
import { connectDashboardSocket } from "./ws/client";
|
import { MediaPanel } from "./components/media/MediaPanel";
|
||||||
import type { MessageRecord } from "./api/client";
|
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||||
import type { DashboardEvent } from "./ws/client";
|
|
||||||
import { MessageFeed } from "./components/messages/MessageFeed";
|
|
||||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||||
|
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||||
|
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||||
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||||
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||||
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||||
|
import { useMediaControl } from "./hooks/useMediaControl";
|
||||||
|
import { useUIState } from "./hooks/useUIState";
|
||||||
|
import { useVoiceControl } from "./hooks/useVoiceControl";
|
||||||
|
import type { MessageRecord } from "./types/messages";
|
||||||
|
import type { DashboardTab } from "./types/ui";
|
||||||
|
import type { ActiveSpeaker } from "./types/voice";
|
||||||
|
|
||||||
function mergeMessages(
|
const SAMPLE_RATE = 24000;
|
||||||
current: MessageRecord[],
|
const CHANNELS = 1;
|
||||||
incoming: MessageRecord[],
|
|
||||||
): MessageRecord[] {
|
|
||||||
const byId = new Map(current.map((message) => [message.id, message]));
|
|
||||||
for (const message of incoming) {
|
|
||||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
|
||||||
}
|
|
||||||
return Array.from(byId.values())
|
|
||||||
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
|
||||||
.slice(0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
const { uiState, setUIState, patchUIState } = useUIState();
|
||||||
const [wsStatus, setWsStatus] = useState<string>("connecting");
|
const voice = useVoiceControl();
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const media = useMediaControl();
|
||||||
|
const messages = useMessages();
|
||||||
|
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||||
|
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
||||||
|
const audioContextListenRef = useRef<AudioContext | null>(null);
|
||||||
|
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const userTimelinesRef = useRef(new Map<number, number>());
|
||||||
|
|
||||||
useEffect(() => {
|
const activeTab = uiState.activeTab || "voice";
|
||||||
let cancelled = false;
|
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||||
|
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
|
||||||
|
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||||
|
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||||
|
|
||||||
listMessages(new URLSearchParams({ limit: "30" }))
|
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
||||||
.then((result) => {
|
const headerView = new DataView(data, 0, 4);
|
||||||
if (!cancelled) {
|
const userIdHash = headerView.getInt32(0, true);
|
||||||
setMessages(mergeMessages([], result.data));
|
const audioData = data.slice(4);
|
||||||
}
|
const int16Array = new Int16Array(audioData);
|
||||||
})
|
let sum = 0;
|
||||||
.catch((err) => {
|
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
||||||
if (!cancelled) {
|
const average = int16Array.length ? sum / int16Array.length : 0;
|
||||||
console.error("Failed to load messages:", err);
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
||||||
}
|
|
||||||
|
const audioContext = audioContextListenRef.current;
|
||||||
|
if (!isListening || !audioContext) return;
|
||||||
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
|
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
||||||
|
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
|
||||||
|
audioBuffer.getChannelData(0).set(float32Array);
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
const currentTime = audioContext.currentTime;
|
||||||
|
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
|
||||||
|
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||||
|
source.start(nextStart);
|
||||||
|
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
const socket = useDashboardSocket({
|
||||||
|
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
|
||||||
|
onUserState: setActiveSpeakers,
|
||||||
|
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||||
|
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
|
||||||
|
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
|
||||||
|
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||||
|
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||||
|
onMediaState: media.setMediaState,
|
||||||
|
onPcm: handleIncomingPcm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = connectDashboardSocket((event: DashboardEvent) => {
|
const stopStreamingLocal = useCallback(() => {
|
||||||
switch (event.type) {
|
setIsStreaming(false);
|
||||||
case "message_created":
|
if (processorRef.current) {
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
processorRef.current.disconnect();
|
||||||
break;
|
processorRef.current = null;
|
||||||
case "message_analyzed":
|
|
||||||
setMessages((prev) => mergeMessages(prev, [event.data]));
|
|
||||||
break;
|
|
||||||
case "message_updated":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) => (m.id === event.data.id ? { ...m, ...event.data } : m)),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "message_deleted":
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === event.data.id ? { ...m, type: "deleted" as const } : m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
if (audioContextTransmitRef.current) {
|
||||||
|
audioContextTransmitRef.current.close();
|
||||||
wsRef.current = ws;
|
audioContextTransmitRef.current = null;
|
||||||
|
}
|
||||||
ws.addEventListener("open", () => setWsStatus("connected"));
|
if (streamRef.current) {
|
||||||
ws.addEventListener("close", () => setWsStatus("disconnected"));
|
for (const track of streamRef.current.getTracks()) track.stop();
|
||||||
ws.addEventListener("error", () => setWsStatus("error"));
|
streamRef.current = null;
|
||||||
|
}
|
||||||
return () => {
|
setLevels(Array.from({ length: 32 }, () => 0.04));
|
||||||
cancelled = true;
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReanalyze = async (id: string) => {
|
const startStreamingLocal = useCallback(async () => {
|
||||||
// Optimistic update
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === id
|
|
||||||
? { ...m, ai_status: "pending" as const, ai_error: null, ai_analysis: null }
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reanalyzeMessage(id);
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
} catch (err) {
|
streamRef.current = stream;
|
||||||
console.error("Reanalyze failed:", err);
|
setIsStreaming(true);
|
||||||
// Revert optimistic update on failure
|
|
||||||
setMessages((prev) =>
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
prev.map((m) =>
|
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
|
audioContextTransmitRef.current = audioContext;
|
||||||
),
|
|
||||||
);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processorRef.current = processor;
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
const pcmData = new Int16Array(inputData.length);
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||||
}
|
}
|
||||||
|
socket.socketRef.current.send(pcmData.buffer);
|
||||||
|
|
||||||
|
// Update local levels from mic
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
|
||||||
|
const average = inputData.length ? sum / inputData.length : 0;
|
||||||
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone access failed:", err);
|
||||||
|
setIsStreaming(false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [socket.socketRef]);
|
||||||
|
|
||||||
|
const toggleStreaming = useCallback(async () => {
|
||||||
|
if (isStreaming) {
|
||||||
|
stopStreamingLocal();
|
||||||
|
await patchUIState({ isStreaming: false });
|
||||||
|
} else {
|
||||||
|
await startStreamingLocal();
|
||||||
|
await patchUIState({ isStreaming: true });
|
||||||
|
}
|
||||||
|
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedVoiceGuild) {
|
||||||
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedVoiceGuild]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextGuild) {
|
||||||
|
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextGuild]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextChannel) {
|
||||||
|
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextChannel]);
|
||||||
|
|
||||||
|
const toggleListening = useCallback(async () => {
|
||||||
|
if (isListening) {
|
||||||
|
await audioContextListenRef.current?.suspend();
|
||||||
|
userTimelinesRef.current.clear();
|
||||||
|
setIsListening(false);
|
||||||
|
await patchUIState({ isListening: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||||
|
await audioContextListenRef.current.resume();
|
||||||
|
setIsListening(true);
|
||||||
|
await patchUIState({ isListening: true });
|
||||||
|
}, [isListening, patchUIState]);
|
||||||
|
|
||||||
|
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<DashboardLayout
|
||||||
<div className="sidebar">
|
activeTab={activeTab}
|
||||||
<div className="sidebar-header">Moderation</div>
|
wsStatus={socket.status}
|
||||||
<div className="sidebar-placeholder">Channels placeholder</div>
|
voiceStatus={voice.voiceStatus}
|
||||||
</div>
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||||
|
>
|
||||||
<div className="main">
|
<div className="md:hidden">
|
||||||
<div className="header">
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<h1>Discord Moderation Dashboard</h1>
|
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
||||||
<span className="ws-status" data-status={wsStatus}>
|
{tabs.map((tab) => (
|
||||||
{wsStatus}
|
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||||
</span>
|
{tab}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
<div className="content">
|
|
||||||
<MessageFeed messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
|
<TabsContent value="voice">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<VoicePanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.voiceChannels}
|
||||||
|
selectedGuild={selectedVoiceGuild}
|
||||||
|
selectedChannel={selectedVoiceChannel}
|
||||||
|
status={voice.voiceStatus}
|
||||||
|
loading={voice.loading}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
levels={levels}
|
||||||
|
isListening={isListening}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
||||||
|
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
||||||
|
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
||||||
|
onDisconnect={() => voice.leaveVoice()}
|
||||||
|
onListenToggle={toggleListening}
|
||||||
|
onStreamingToggle={toggleStreaming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="media">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<MediaPanel
|
||||||
|
state={media.mediaState}
|
||||||
|
loading={media.loading}
|
||||||
|
onQueueMusic={(source) => media.enqueue(source, "music")}
|
||||||
|
onStartScreen={(source) => media.enqueue(source, "screen")}
|
||||||
|
onSkip={media.skip}
|
||||||
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="messages">
|
||||||
|
<MessagesPanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.textChannels}
|
||||||
|
selectedGuild={selectedTextGuild}
|
||||||
|
selectedChannel={selectedTextChannel}
|
||||||
|
messages={messages.messages}
|
||||||
|
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
|
||||||
|
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
|
||||||
|
onReanalyze={messages.reanalyze}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="review">
|
||||||
|
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
8
frontend/src/api/auth.ts
Normal file
8
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<{ ok: boolean }> {
|
||||||
|
return request<{ ok: boolean }>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,9 +49,13 @@ class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const password = localStorage.getItem("admin-password");
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(password ? { "X-Admin-Password": password } : {}),
|
||||||
|
},
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
28
frontend/src/api/media.ts
Normal file
28
frontend/src/api/media.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { MediaMode, MediaState } from "../types/media";
|
||||||
|
|
||||||
|
export function getMediaStatus(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueMedia(source: string, mode: MediaMode): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ source, mode }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skipMedia(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/skip', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMedia(): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaVolume(volume: number): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/volume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ volume }),
|
||||||
|
});
|
||||||
|
}
|
||||||
3
frontend/src/api/messages.ts
Normal file
3
frontend/src/api/messages.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { listMessages, listReview, reanalyzeMessage } from "./client";
|
||||||
|
|
||||||
|
export { listMessages, listReview, reanalyzeMessage };
|
||||||
13
frontend/src/api/uiState.ts
Normal file
13
frontend/src/api/uiState.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
|
||||||
|
export function getUIState(): Promise<UIState> {
|
||||||
|
return request<UIState>('/api/ui-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||||
|
return request<UIState>('/api/ui-state', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
29
frontend/src/api/voice.ts
Normal file
29
frontend/src/api/voice.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||||
|
|
||||||
|
export function getGuilds(): Promise<Guild[]> {
|
||||||
|
return request<Guild[]>('/api/guilds');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||||
|
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectVoice(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ guildId, channelId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectVoice(): Promise<VoiceStatus> {
|
||||||
|
return request<VoiceStatus>('/api/disconnect', { method: 'POST' });
|
||||||
|
}
|
||||||
62
frontend/src/components/layout/AuthOverlay.tsx
Normal file
62
frontend/src/components/layout/AuthOverlay.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { login } from "../../api/auth";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
interface AuthOverlayProps {
|
||||||
|
onAuthenticated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(password);
|
||||||
|
localStorage.setItem("admin-password", password);
|
||||||
|
onAuthenticated();
|
||||||
|
} catch (err) {
|
||||||
|
setError("Invalid password");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<Lock className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Admin Access Required</CardTitle>
|
||||||
|
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !password}>
|
||||||
|
{loading ? "Authenticating..." : "Unlock Controls"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/layout/DashboardLayout.tsx
Normal file
28
frontend/src/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import type { VoiceStatus } from "../../types/voice";
|
||||||
|
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
voiceStatus: VoiceStatus;
|
||||||
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout({ activeTab, wsStatus, voiceStatus, onTabChange, children }: DashboardLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<Header activeTab={activeTab} wsStatus={wsStatus} voiceStatus={voiceStatus} />
|
||||||
|
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/layout/Header.tsx
Normal file
40
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import type { VoiceStatus } from "../../types/voice";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
|
const titles: Record<DashboardTab, string> = {
|
||||||
|
voice: "Voice Control",
|
||||||
|
media: "Media Player",
|
||||||
|
messages: "Messages",
|
||||||
|
review: "Moderation Review",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
voiceStatus: VoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
|
||||||
|
{wsStatus === "connected" ? <Wifi className="mr-1 h-3 w-3" /> : <WifiOff className="mr-1 h-3 w-3" />}
|
||||||
|
WebSocket {wsStatus}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
|
||||||
|
Voice {voiceStatus.connected ? voiceStatus.activeChannelName || "connected" : "idle"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/layout/Sidebar.tsx
Normal file
48
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
|
||||||
|
import type { DashboardTab } from "../../types/ui";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
|
||||||
|
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||||
|
{ id: "media", label: "Media", icon: Music2 },
|
||||||
|
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||||
|
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
activeTab: DashboardTab;
|
||||||
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
|
||||||
|
<div className="mb-8 flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/15 text-primary">
|
||||||
|
<Bot className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold tracking-tight">Bete Watcher</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Discord control center</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant={activeTab === item.id ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
|
||||||
|
onClick={() => onTabChange(item.id)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/media/MediaPanel.tsx
Normal file
50
frontend/src/components/media/MediaPanel.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MediaState } from "../../types/media";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import { MediaQueue } from "./MediaQueue";
|
||||||
|
import { MusicPlayer } from "./MusicPlayer";
|
||||||
|
import { ScreenShare } from "./ScreenShare";
|
||||||
|
|
||||||
|
interface MediaPanelProps {
|
||||||
|
state: MediaState;
|
||||||
|
loading: boolean;
|
||||||
|
onQueueMusic: (source: string) => void;
|
||||||
|
onStartScreen: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaPanel({
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
onQueueMusic,
|
||||||
|
onStartScreen,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
onVolumeChange,
|
||||||
|
}: MediaPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||||
|
<Tabs defaultValue="music" className="min-w-0">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="music">Music</TabsTrigger>
|
||||||
|
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="music">
|
||||||
|
<MusicPlayer
|
||||||
|
loading={loading}
|
||||||
|
volume={state.musicVolume}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onQueue={onQueueMusic}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onStop={onStop}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="screen">
|
||||||
|
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<MediaQueue state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/media/MediaQueue.tsx
Normal file
46
frontend/src/components/media/MediaQueue.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { MediaState } from "../../types/media";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface MediaQueueProps {
|
||||||
|
state: MediaState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaQueue({ state }: MediaQueueProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Now Playing</CardTitle>
|
||||||
|
<CardDescription>Current item and queue state.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{state.current ? (
|
||||||
|
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{state.current.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Queue</div>
|
||||||
|
{state.queue.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Queue is empty.</div>
|
||||||
|
) : (
|
||||||
|
state.queue.map((item, index) => (
|
||||||
|
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/media/MusicPlayer.tsx
Normal file
84
frontend/src/components/media/MusicPlayer.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Music2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
|
interface MusicPlayerProps {
|
||||||
|
loading: boolean;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
onQueue: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MusicPlayer({
|
||||||
|
loading,
|
||||||
|
volume,
|
||||||
|
onVolumeChange,
|
||||||
|
onQueue,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
}: MusicPlayerProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||||
|
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftVolume(Math.round(safeVolume * 100));
|
||||||
|
}, [safeVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalized = draftVolume / 100;
|
||||||
|
if (Math.abs(normalized - safeVolume) < 0.001) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onVolumeChange(normalized);
|
||||||
|
}, 150);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [draftVolume, onVolumeChange, safeVolume]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onQueue(trimmed);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Music2 className="h-5 w-5" /> Music Player</CardTitle>
|
||||||
|
<CardDescription>Play YouTube, Spotify tracks, search terms, or local files as audio.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">Volume</span>
|
||||||
|
<span className="text-muted-foreground">{draftVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={draftVolume}
|
||||||
|
onChange={(event) => setDraftVolume(Number(event.target.value))}
|
||||||
|
className="h-2 w-full cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/media/ScreenShare.tsx
Normal file
45
frontend/src/components/media/ScreenShare.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { MonitorUp } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
|
interface ScreenShareProps {
|
||||||
|
loading: boolean;
|
||||||
|
onStart: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onStart(trimmed);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
|
||||||
|
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
|
placeholder="Screen share URL or local file path"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/messages/ImageGrid.tsx
Normal file
43
frontend/src/components/messages/ImageGrid.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { MessageMetadata, MessageRecord } from "../../types/messages";
|
||||||
|
|
||||||
|
function parseMetadata(value: string | null): MessageMetadata {
|
||||||
|
if (!value) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as MessageMetadata;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||||
|
const images = messages.flatMap((message) => {
|
||||||
|
const metadata = parseMetadata(message.metadata);
|
||||||
|
const attachments = metadata.attachments ?? [];
|
||||||
|
const embeds = metadata.embeds ?? [];
|
||||||
|
return [
|
||||||
|
...attachments
|
||||||
|
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
|
||||||
|
.map((attachment) => ({ url: attachment.url, title: attachment.name, message })),
|
||||||
|
...embeds
|
||||||
|
.flatMap((embed) => [embed.image, embed.thumbnail].filter(Boolean).map((url) => ({ url: url as string, title: embed.title || "embed image", message }))),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||||
|
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +1,51 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import { RotateCw } from "lucide-react";
|
||||||
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
export interface MessageCardProps {
|
export interface MessageCardProps {
|
||||||
message: MessageRecord;
|
message: MessageRecord;
|
||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
function aiVariant(status: string) {
|
||||||
pending: "#f9e2af",
|
if (status === "clean") return "success";
|
||||||
clean: "#a6e3a1",
|
if (status === "warn") return "warning";
|
||||||
warn: "#fab387",
|
if (status === "flagged" || status === "error") return "destructive";
|
||||||
flagged: "#f38ba8",
|
return "secondary";
|
||||||
error: "#f38ba8",
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||||
const displayContent = message.edited_content ?? message.content;
|
const displayContent = message.edited_content ?? message.content;
|
||||||
const aiStatus = message.ai_status ?? "pending";
|
const aiStatus = message.ai_status ?? "pending";
|
||||||
const statusColor = STATUS_COLORS[aiStatus] ?? "#6c7086";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message-card type-${message.type}`}>
|
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
<img
|
<img
|
||||||
src={message.avatar_url ?? "/default-avatar.png"}
|
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
alt={message.username}
|
alt=""
|
||||||
className="message-card-avatar"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
/>
|
/>
|
||||||
<div className="message-card-body">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<div className="message-card-meta">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="message-card-username">{message.username}</span>
|
<span className="font-medium">{message.username || message.user_id}</span>
|
||||||
<span className="message-card-time">
|
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
|
||||||
{new Date(message.created_at).toLocaleString()}
|
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
|
||||||
</span>
|
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
|
||||||
{message.type === "edited" && (
|
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
|
||||||
<span className="badge badge-edited">edited</span>
|
|
||||||
)}
|
|
||||||
{message.type === "deleted" && (
|
|
||||||
<span className="badge badge-deleted">deleted</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="badge badge-ai"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={`AI: ${aiStatus}`}
|
|
||||||
>
|
|
||||||
{aiStatus}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="message-card-content">{displayContent}</p>
|
|
||||||
|
|
||||||
{message.ai_analysis && (
|
|
||||||
<div className="message-card-analysis">{message.ai_analysis}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message.ai_error && (
|
|
||||||
<div className="message-card-error">{message.ai_error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="message-card-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-reanalyze"
|
|
||||||
onClick={() => onReanalyze(message.id)}
|
|
||||||
disabled={aiStatus === "pending"}
|
|
||||||
>
|
|
||||||
Reanalyze
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||||
|
{displayContent || "(empty message)"}
|
||||||
|
</p>
|
||||||
|
{message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null}
|
||||||
|
{message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
import { MessageCard } from "./MessageCard";
|
import { MessageCard } from "./MessageCard";
|
||||||
|
|
||||||
export interface MessageFeedProps {
|
export interface MessageFeedProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
onReanalyze: (id: string) => void;
|
onReanalyze: (id: string) => void;
|
||||||
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageFeed({ messages, onReanalyze }: MessageFeedProps) {
|
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
|
||||||
<div className="empty-state">
|
|
||||||
<p>No messages yet</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-feed">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
{messages.map((msg) => (
|
<div className="space-y-3">
|
||||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
{messages.map((message) => (
|
||||||
|
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
frontend/src/components/messages/MessagesPanel.tsx
Normal file
66
frontend/src/components/messages/MessagesPanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Channel, Guild } from "../../types/voice";
|
||||||
|
import type { MessageRecord } from "../../types/messages";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select } from "../ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import { ImageGrid } from "./ImageGrid";
|
||||||
|
import { MessageFeed } from "./MessageFeed";
|
||||||
|
|
||||||
|
interface MessagesPanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
messages: MessageRecord[];
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onReanalyze: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagesPanel({
|
||||||
|
guilds,
|
||||||
|
channels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
messages,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onReanalyze,
|
||||||
|
}: MessagesPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Message Source</CardTitle>
|
||||||
|
<CardDescription>Pick a guild and channel/thread to inspect captures.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(event) => onGuildChange(event.target.value)}
|
||||||
|
placeholder="Select text guild"
|
||||||
|
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
placeholder="Select channel or thread"
|
||||||
|
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Tabs defaultValue="all">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All Messages</TabsTrigger>
|
||||||
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="all">
|
||||||
|
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="images">
|
||||||
|
<ImageGrid messages={messages} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { MessageRecord } from "../../api/client";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
import { MessageCard } from "../messages/MessageCard";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { MessageFeed } from "../messages/MessageFeed";
|
||||||
|
|
||||||
export interface ReviewPanelProps {
|
export interface ReviewPanelProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
@@ -8,30 +9,21 @@ export interface ReviewPanelProps {
|
|||||||
|
|
||||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||||
const reviewItems = messages.filter(
|
const reviewItems = messages.filter(
|
||||||
(m) =>
|
(message) =>
|
||||||
m.ai_status === "warn" ||
|
message.ai_status === "warn" ||
|
||||||
m.ai_status === "flagged" ||
|
message.ai_status === "flagged" ||
|
||||||
m.ai_status === "error",
|
message.ai_status === "error",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="review-panel">
|
<Card>
|
||||||
<div className="review-header">
|
<CardHeader>
|
||||||
<h2>Needs Review</h2>
|
<CardTitle>Needs Review</CardTitle>
|
||||||
<span className="review-count">{reviewItems.length}</span>
|
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{reviewItems.length === 0 ? (
|
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
|
||||||
<div className="empty-state">
|
</CardContent>
|
||||||
<p>No items to review</p>
|
</Card>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="review-list">
|
|
||||||
{reviewItems.map((msg) => (
|
|
||||||
<MessageCard key={msg.id} message={msg} onReanalyze={onReanalyze} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
30
frontend/src/components/ui/badge.tsx
Normal file
30
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
|
||||||
|
|
||||||
|
const variants: Record<BadgeVariant, string> = {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
outline: "text-foreground",
|
||||||
|
success: "border-transparent bg-emerald-500/15 text-emerald-300",
|
||||||
|
warning: "border-transparent bg-amber-500/15 text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
variants[variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/button.tsx
Normal file
48
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost";
|
||||||
|
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||||
|
|
||||||
|
const variants: Record<ButtonVariant, string> = {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<ButtonSize, string> = {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/card.tsx
Normal file
26
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
15
frontend/src/components/ui/input.tsx
Normal file
15
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/ui/scroll-area.tsx
Normal file
30
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function ScrollArea({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/ui/select.tsx
Normal file
31
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ className, options, placeholder, ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder ? <option value="">{placeholder}</option> : null}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/tabs.tsx
Normal file
35
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
className={cn("inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
className={cn("mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/voice/ActiveSpeakers.tsx
Normal file
35
frontend/src/components/voice/ActiveSpeakers.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ActiveSpeaker } from "../../types/voice";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface ActiveSpeakersProps {
|
||||||
|
speakers: ActiveSpeaker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Speakers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{speakers.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No active speakers.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{speakers.map((speaker, index) => (
|
||||||
|
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||||
|
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{speaker.username}</div>
|
||||||
|
<div className="text-xs text-emerald-300">Speaking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/voice/AudioVisualizer.tsx
Normal file
18
frontend/src/components/voice/AudioVisualizer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
interface AudioVisualizerProps {
|
||||||
|
levels: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||||
|
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
|
||||||
|
{bars.map((level, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${level}`}
|
||||||
|
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
|
||||||
|
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/components/voice/VoiceControl.tsx
Normal file
83
frontend/src/components/voice/VoiceControl.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select } from "../ui/select";
|
||||||
|
|
||||||
|
interface VoiceControlProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
loading: boolean;
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceControl({
|
||||||
|
guilds,
|
||||||
|
channels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onJoin,
|
||||||
|
onDisconnect,
|
||||||
|
onListenToggle,
|
||||||
|
onStreamingToggle,
|
||||||
|
isListening,
|
||||||
|
isStreaming,
|
||||||
|
}: VoiceControlProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Voice Bridge</CardTitle>
|
||||||
|
<CardDescription>Join a Discord voice channel and monitor audio in real time.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Guild</label>
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(event) => onGuildChange(event.target.value)}
|
||||||
|
placeholder="Select guild"
|
||||||
|
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Voice Channel</label>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
placeholder="Select voice channel"
|
||||||
|
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={!selectedGuild || !selectedChannel || loading} onClick={onJoin}>
|
||||||
|
{status.connected ? "Reconnect" : "Join Voice"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={!status.connected || loading} onClick={onDisconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
|
||||||
|
{isListening ? "Stop Listening" : "Listen Live"}
|
||||||
|
</Button>
|
||||||
|
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
|
||||||
|
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/voice/VoicePanel.tsx
Normal file
43
frontend/src/components/voice/VoicePanel.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||||
|
import { AudioVisualizer } from "./AudioVisualizer";
|
||||||
|
import { ActiveSpeakers } from "./ActiveSpeakers";
|
||||||
|
import { VoiceControl } from "./VoiceControl";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
|
||||||
|
interface VoicePanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
loading: boolean;
|
||||||
|
activeSpeakers: ActiveSpeaker[];
|
||||||
|
levels: number[];
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoicePanel(props: VoicePanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<VoiceControl {...props} />
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Live Audio Visualizer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AudioVisualizer levels={props.levels} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<ActiveSpeakers speakers={props.activeSpeakers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/hooks/useDashboardSocket.ts
Normal file
96
frontend/src/hooks/useDashboardSocket.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { MessageRecord } from "../types/messages";
|
||||||
|
import type { MediaState } from "../types/media";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
import type { ActiveSpeaker } from "../types/voice";
|
||||||
|
|
||||||
|
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||||
|
|
||||||
|
export interface DashboardSocketHandlers {
|
||||||
|
onUIState?: (state: UIState) => void;
|
||||||
|
onUserState?: (users: ActiveSpeaker[]) => void;
|
||||||
|
onMessageCreated?: (message: MessageRecord) => void;
|
||||||
|
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
|
||||||
|
onMessageDeleted?: (message: { id: string }) => void;
|
||||||
|
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||||
|
onAttachmentUploaded?: () => void;
|
||||||
|
onMediaState?: (state: MediaState) => void;
|
||||||
|
onPcm?: (data: ArrayBuffer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
||||||
|
const [status, setStatus] = useState<WebSocketStatus>("connecting");
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socketRef.current = socket;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => setStatus("connected"));
|
||||||
|
socket.addEventListener("error", () => setStatus("error"));
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
setStatus("disconnected");
|
||||||
|
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
|
||||||
|
});
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
handlersRef.current.onPcm?.(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof event.data !== "string") return;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
switch (message.type) {
|
||||||
|
case "ui_state":
|
||||||
|
handlersRef.current.onUIState?.(message.state);
|
||||||
|
break;
|
||||||
|
case "user_state":
|
||||||
|
handlersRef.current.onUserState?.(message.users || []);
|
||||||
|
break;
|
||||||
|
case "message_created":
|
||||||
|
handlersRef.current.onMessageCreated?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_updated":
|
||||||
|
handlersRef.current.onMessageUpdated?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_deleted":
|
||||||
|
handlersRef.current.onMessageDeleted?.(message.data);
|
||||||
|
break;
|
||||||
|
case "message_analyzed":
|
||||||
|
handlersRef.current.onMessageAnalyzed?.(message.data);
|
||||||
|
break;
|
||||||
|
case "attachment_uploaded":
|
||||||
|
handlersRef.current.onAttachmentUploaded?.();
|
||||||
|
break;
|
||||||
|
case "media_state":
|
||||||
|
handlersRef.current.onMediaState?.(message.state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed socket messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||||
|
socketRef.current?.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, socketRef };
|
||||||
|
}
|
||||||
97
frontend/src/hooks/useMediaControl.ts
Normal file
97
frontend/src/hooks/useMediaControl.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
getMediaStatus,
|
||||||
|
queueMedia,
|
||||||
|
setMediaVolume,
|
||||||
|
skipMedia,
|
||||||
|
stopMedia,
|
||||||
|
} from "../api/media";
|
||||||
|
import type { MediaMode, MediaState } from "../types/media";
|
||||||
|
|
||||||
|
const emptyMediaState: MediaState = {
|
||||||
|
playing: false,
|
||||||
|
musicVolume: 1,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMediaControl() {
|
||||||
|
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshMedia = useCallback(async () => {
|
||||||
|
const state = await getMediaStatus();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await queueMedia(source, mode);
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const skip = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await skipMedia();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await stopMedia();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback(async (volume: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const state = await setMediaVolume(volume);
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}, [refreshMedia]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaState,
|
||||||
|
setMediaState,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshMedia,
|
||||||
|
enqueue,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
frontend/src/hooks/useMessages.ts
Normal file
58
frontend/src/hooks/useMessages.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { listMessages, reanalyzeMessage } from "../api/messages";
|
||||||
|
import type { MessageRecord } from "../types/messages";
|
||||||
|
|
||||||
|
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
||||||
|
const byId = new Map(current.map((message) => [message.id, message]));
|
||||||
|
for (const message of incoming) {
|
||||||
|
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||||
|
}
|
||||||
|
return Array.from(byId.values())
|
||||||
|
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages() {
|
||||||
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||||
|
if (!channelId) {
|
||||||
|
setMessages([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: "80" });
|
||||||
|
params.set("channel", channelId);
|
||||||
|
const result = await listMessages(params);
|
||||||
|
setMessages(result.data);
|
||||||
|
return result.data;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reanalyze = useCallback(async (id: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === id
|
||||||
|
? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null }
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await reanalyzeMessage(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages().catch(() => undefined);
|
||||||
|
}, [fetchMessages]);
|
||||||
|
|
||||||
|
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useUIState.ts
Normal file
35
frontend/src/hooks/useUIState.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getUIState, updateUIState } from "../api/uiState";
|
||||||
|
import type { UIState } from "../types/ui";
|
||||||
|
|
||||||
|
export function useUIState() {
|
||||||
|
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getUIState()
|
||||||
|
.then((state) => {
|
||||||
|
if (!cancelled) setUIState({ activeTab: "voice", ...state });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
|
||||||
|
setUIState((prev) => ({ ...prev, ...patch }));
|
||||||
|
const next = await updateUIState(patch);
|
||||||
|
setUIState((prev) => ({ ...prev, ...next }));
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { uiState, setUIState, patchUIState, loading, error };
|
||||||
|
}
|
||||||
104
frontend/src/hooks/useVoiceControl.ts
Normal file
104
frontend/src/hooks/useVoiceControl.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../api/voice";
|
||||||
|
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||||
|
|
||||||
|
export function useVoiceControl() {
|
||||||
|
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||||
|
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
||||||
|
const [textChannels, setTextChannels] = useState<Channel[]>([]);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({ connected: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshGuilds = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
const nextGuilds = await getGuilds();
|
||||||
|
setGuilds(nextGuilds);
|
||||||
|
return nextGuilds;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshVoiceStatus = useCallback(async () => {
|
||||||
|
const status = await getVoiceStatus();
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setVoiceChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getVoiceChannels(guildId);
|
||||||
|
setVoiceChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setTextChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getTextChannels(guildId);
|
||||||
|
setTextChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await connectVoice(guildId, channelId);
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leaveVoice = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await disconnectVoice();
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshGuilds().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
refreshVoiceStatus().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}, [refreshGuilds, refreshVoiceStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
textChannels,
|
||||||
|
voiceStatus,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshGuilds,
|
||||||
|
refreshVoiceStatus,
|
||||||
|
loadVoiceChannels,
|
||||||
|
loadTextTargets,
|
||||||
|
joinVoice,
|
||||||
|
leaveVoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,341 +1,41 @@
|
|||||||
* {
|
@tailwind base;
|
||||||
box-sizing: border-box;
|
@tailwind components;
|
||||||
margin: 0;
|
@tailwind utilities;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
:root {
|
||||||
background: #1e1e2e;
|
--background: 222 47% 7%;
|
||||||
color: #cdd6f4;
|
--foreground: 210 40% 98%;
|
||||||
height: 100vh;
|
--card: 222 47% 10%;
|
||||||
overflow: hidden;
|
--card-foreground: 210 40% 98%;
|
||||||
}
|
--primary: 199 89% 48%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 217 33% 17%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217 33% 17%;
|
||||||
|
--muted-foreground: 215 20% 65%;
|
||||||
|
--accent: 217 33% 17%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217 33% 20%;
|
||||||
|
--input: 217 33% 20%;
|
||||||
|
--ring: 199 89% 48%;
|
||||||
|
--radius: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
* {
|
||||||
height: 100%;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
body {
|
||||||
display: flex;
|
@apply bg-background text-foreground antialiased;
|
||||||
height: 100vh;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
html,
|
||||||
width: 220px;
|
body,
|
||||||
background: #181825;
|
#root {
|
||||||
border-right: 1px solid #313244;
|
min-height: 100%;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #cdd6f4;
|
|
||||||
border-bottom: 1px solid #313244;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-placeholder {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c7086;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-bottom: 1px solid #313244;
|
|
||||||
background: #181825;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #cdd6f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-status {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-status[data-status="connected"] {
|
|
||||||
background: #a6e3a1;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-status[data-status="disconnected"],
|
|
||||||
.ws-status[data-status="error"] {
|
|
||||||
background: #f38ba8;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-status[data-status="connecting"] {
|
|
||||||
background: #f9e2af;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
color: #6c7086;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #181825;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #313244;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-item.type-deleted {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-username {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #89b4fa;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6c7086;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-deleted {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #f38ba8;
|
|
||||||
margin-left: 8px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #bac2de;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-panel {
|
|
||||||
border-top: 1px solid #313244;
|
|
||||||
background: #181825;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message Card */
|
|
||||||
.message-card {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #181825;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #313244;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card.type-deleted {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-username {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #89b4fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6c7086;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-edited {
|
|
||||||
background: #fab387;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-deleted {
|
|
||||||
background: #f38ba8;
|
|
||||||
color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-content {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #bac2de;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-analysis {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background: #1e1e2e;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #a6e3a1;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-error {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background: #1e1e2e;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card-actions {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-reanalyze {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #313244;
|
|
||||||
color: #cdd6f4;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-reanalyze:hover:not(:disabled) {
|
|
||||||
background: #45475a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-reanalyze:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message Feed */
|
|
||||||
.message-feed {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Review Panel */
|
|
||||||
.review-panel {
|
|
||||||
border-top: 1px solid #313244;
|
|
||||||
background: #181825;
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #313244;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-header h2 {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #cdd6f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-count {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f38ba8;
|
|
||||||
color: #1e1e2e;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-placeholder {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c7086;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-panel .empty-state {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep existing styles */
|
|
||||||
.review-placeholder {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c7086;
|
|
||||||
}
|
}
|
||||||
7
frontend/src/types/browser.ts
Normal file
7
frontend/src/types/browser.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/types/media.ts
Normal file
17
frontend/src/types/media.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
|
||||||
|
export interface MediaItem {
|
||||||
|
id?: string;
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
mode?: MediaMode;
|
||||||
|
durationMs?: number | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
|
current: MediaItem | null;
|
||||||
|
queue: MediaItem[];
|
||||||
|
}
|
||||||
29
frontend/src/types/messages.ts
Normal file
29
frontend/src/types/messages.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type { AIStatus, MessageRecord, PageResult } from "../api/client";
|
||||||
|
|
||||||
|
export interface MessageMetadataAttachment {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
contentType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageMetadataEmbed {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
url?: string;
|
||||||
|
image?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageMetadataSticker {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageMetadata {
|
||||||
|
attachments?: MessageMetadataAttachment[];
|
||||||
|
embeds?: MessageMetadataEmbed[];
|
||||||
|
stickers?: MessageMetadataSticker[];
|
||||||
|
reference?: { messageId?: string };
|
||||||
|
channel?: { threadName?: string };
|
||||||
|
}
|
||||||
12
frontend/src/types/ui.ts
Normal file
12
frontend/src/types/ui.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type DashboardTab = "voice" | "media" | "messages" | "review";
|
||||||
|
|
||||||
|
export interface UIState {
|
||||||
|
selectedGuild?: string;
|
||||||
|
selectedVoiceGuild?: string;
|
||||||
|
selectedVoiceChannel?: string;
|
||||||
|
selectedTextGuild?: string;
|
||||||
|
selectedTextChannel?: string;
|
||||||
|
activeTab?: DashboardTab;
|
||||||
|
isListening?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
27
frontend/src/types/voice.ts
Normal file
27
frontend/src/types/voice.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface Guild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceStatus {
|
||||||
|
connected: boolean;
|
||||||
|
activeGuildId?: string | null;
|
||||||
|
activeChannelId?: string | null;
|
||||||
|
activeChannelName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSpeaker {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
speaking: boolean;
|
||||||
|
}
|
||||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
||||||
|
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
||||||
|
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
||||||
|
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
||||||
|
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
||||||
|
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
13
package.json
13
package.json
@@ -2,6 +2,7 @@
|
|||||||
"name": "discord-voice-recorder",
|
"name": "discord-voice-recorder",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Discord bot that joins a voice channel and records audio",
|
"description": "Discord bot that joins a voice channel and records audio",
|
||||||
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"packageManager": "pnpm@10.25.0",
|
"packageManager": "pnpm@10.25.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,19 +23,25 @@
|
|||||||
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dank074/discord-video-stream": "workspace:*",
|
"@dank074/discord-video-stream": "^6.0.0",
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.1",
|
"@discordjs/voice": "^0.19.1",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@snazzah/davey": "^0.1.10",
|
"@snazzah/davey": "^0.1.10",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"discord.js-selfbot-v13": "workspace:*",
|
"discord.js-selfbot-v13": "workspace:*",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"libsodium-wrappers": "^0.8.2",
|
"libsodium-wrappers": "^0.8.2",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"p-retry": "^8.0.0",
|
"p-retry": "^8.0.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
@@ -45,6 +52,7 @@
|
|||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"sodium-native": "^5.1.0",
|
"sodium-native": "^5.1.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"vite": "^8.0.13",
|
"vite": "^8.0.13",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.20.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -57,8 +65,11 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.22.0",
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
|
|||||||
1876
pnpm-lock.yaml
generated
1876
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- vendor/discord.js-selfbot-v13
|
- vendor/discord.js-selfbot-v13
|
||||||
- vendor/Discord-video-stream
|
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@discordjs/opus'
|
- '@discordjs/opus'
|
||||||
|
|||||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #080a0f;
|
|
||||||
--panel: rgba(18, 22, 32, 0.86);
|
|
||||||
--panel-strong: #121720;
|
|
||||||
--line: rgba(255, 255, 255, 0.12);
|
|
||||||
--text: #edf4ff;
|
|
||||||
--muted: #91a0b6;
|
|
||||||
--faint: #536176;
|
|
||||||
--cyan: #00e5ff;
|
|
||||||
--green: #39ff88;
|
|
||||||
--yellow: #ffe45e;
|
|
||||||
--red: #ff4f6d;
|
|
||||||
--blue: #6275ff;
|
|
||||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: Manrope, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
|
|
||||||
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
|
|
||||||
linear-gradient(145deg, #05060a, var(--bg));
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
mask-image: linear-gradient(to bottom, black, transparent 84%);
|
|
||||||
}
|
|
||||||
|
|
||||||
button, select { font: inherit; }
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
width: min(1440px, calc(100% - 32px));
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 28px 0 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.2fr 0.8fr;
|
|
||||||
gap: 18px;
|
|
||||||
align-items: stretch;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-card,
|
|
||||||
.status-card,
|
|
||||||
.tab-panel,
|
|
||||||
.content-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--panel);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-card {
|
|
||||||
position: relative;
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 28px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 190px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-card::after {
|
|
||||||
content: "WATCHER";
|
|
||||||
position: absolute;
|
|
||||||
right: -14px;
|
|
||||||
bottom: -20px;
|
|
||||||
font-family: "Archivo Black", sans-serif;
|
|
||||||
font-size: clamp(58px, 9vw, 132px);
|
|
||||||
letter-spacing: -0.08em;
|
|
||||||
color: rgba(255,255,255,0.035);
|
|
||||||
line-height: 0.78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
color: var(--cyan);
|
|
||||||
font: 700 12px/1 "JetBrains Mono", monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--green);
|
|
||||||
box-shadow: 0 0 20px var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 840px;
|
|
||||||
font-family: "Archivo Black", sans-serif;
|
|
||||||
font-size: clamp(40px, 6vw, 82px);
|
|
||||||
line-height: 0.88;
|
|
||||||
letter-spacing: -0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 18px 0 0;
|
|
||||||
max-width: 720px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 24px;
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 0;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row:last-child { border-bottom: 0; }
|
|
||||||
|
|
||||||
.status-label {
|
|
||||||
color: var(--muted);
|
|
||||||
font: 700 12px/1 "JetBrains Mono", monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-value {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
font: 700 13px/1 "JetBrains Mono", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 99px;
|
|
||||||
background: var(--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
|
|
||||||
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
|
|
||||||
|
|
||||||
.tab-panel {
|
|
||||||
position: sticky;
|
|
||||||
top: 14px;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 800;
|
|
||||||
transition: 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
|
||||||
.tab-btn.active {
|
|
||||||
color: #061014;
|
|
||||||
background: linear-gradient(135deg, var(--cyan), var(--green));
|
|
||||||
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
min-width: 240px;
|
|
||||||
color: var(--text);
|
|
||||||
background: rgba(5,8,14,0.78);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 11px 14px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content { display: none; }
|
|
||||||
.tab-content.active { display: block; }
|
|
||||||
|
|
||||||
.voice-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 380px 1fr;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-card {
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Archivo Black", sans-serif;
|
|
||||||
font-size: 26px;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini {
|
|
||||||
color: var(--faint);
|
|
||||||
font: 700 11px/1 "JetBrains Mono", monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
|
|
||||||
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
|
|
||||||
|
|
||||||
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 13px 16px;
|
|
||||||
color: #061014;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 900;
|
|
||||||
transition: transform 140ms ease, filter 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
|
||||||
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
|
|
||||||
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
|
|
||||||
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
|
|
||||||
|
|
||||||
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
|
|
||||||
|
|
||||||
.visualizer {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 5px;
|
|
||||||
height: 130px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(0,0,0,0.22);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 5px;
|
|
||||||
border-radius: 999px;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
|
|
||||||
box-shadow: 0 0 18px rgba(0,229,255,0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.participants {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255,255,255,0.035);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
|
|
||||||
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
|
|
||||||
|
|
||||||
.feed {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(255,255,255,0.035);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(135deg, var(--blue), var(--cyan));
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
|
|
||||||
|
|
||||||
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
|
|
||||||
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
|
||||||
|
|
||||||
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
|
|
||||||
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
|
|
||||||
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
|
|
||||||
.embed-title { font-weight: 900; color: var(--text); }
|
|
||||||
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
|
|
||||||
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
|
|
||||||
|
|
||||||
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.badge {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 5px 9px;
|
|
||||||
color: var(--muted);
|
|
||||||
font: 700 10px/1 "JetBrains Mono", monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
|
|
||||||
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
|
|
||||||
|
|
||||||
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
|
|
||||||
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
|
|
||||||
.link:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 34px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--faint);
|
|
||||||
border: 1px dashed var(--line);
|
|
||||||
border-radius: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: none;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
color: #ffd8df;
|
|
||||||
border: 1px solid rgba(255,79,109,0.5);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,79,109,0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.hero, .voice-layout { grid-template-columns: 1fr; }
|
|
||||||
.tab-panel { align-items: stretch; flex-direction: column; }
|
|
||||||
.filter-row { align-items: stretch; flex-direction: column; }
|
|
||||||
select { width: 100%; min-width: 0; }
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Discord Moderation Watcher</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="brand-card">
|
|
||||||
<div class="eyebrow"><span class="pulse"></span> Discord moderation watcher</div>
|
|
||||||
<h1>Voice. Text. One Watch Floor.</h1>
|
|
||||||
<p class="subtitle">Static shared-state dashboard with legacy working voice bridge and captured Discord messages.</p>
|
|
||||||
</div>
|
|
||||||
<div class="status-card">
|
|
||||||
<div class="status-row"><span class="status-label">WebSocket</span><span class="status-value"><span id="wsDot" class="dot"></span><span id="wsStatusText">Connecting</span></span></div>
|
|
||||||
<div class="status-row"><span class="status-label">Voice Link</span><span id="voiceStatusText" class="status-value">Not connected</span></div>
|
|
||||||
<div class="status-row"><span class="status-label">Active Tab</span><span id="activeTabLabel" class="status-value">Voice</span></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav class="tab-panel">
|
|
||||||
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
|
||||||
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="errorBox" class="error"></div>
|
|
||||||
|
|
||||||
<section id="voice" class="tab-content active">
|
|
||||||
<div class="voice-layout">
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
|
||||||
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
|
|
||||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
|
||||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
|
||||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Live Audio</h2><span class="mini" id="listenStatus">Speaker Off</span></div>
|
|
||||||
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
|
||||||
<div class="visualizer" id="visualizer"></div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card">
|
|
||||||
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
|
|
||||||
<div class="field-group"><label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label><input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms"></div>
|
|
||||||
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
|
|
||||||
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="text" class="tab-content"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;height:100%"><div class="content-card"><div class="card-title"><h2>All Messages</h2><span class="mini">all captures</span></div><div id="textList" class="feed"><div class="empty">Select channel to view text captures</div></div></div><div class="content-card"><div class="card-title"><h2>Needs Review</h2><span class="mini">warn + flagged</span></div><div id="reviewList" class="feed"><div class="empty">No warned or flagged messages</div></div></div></div></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const state = {
|
|
||||||
socket: null,
|
|
||||||
selectedVoiceGuild: '',
|
|
||||||
selectedVoiceChannel: '',
|
|
||||||
selectedTextGuild: '',
|
|
||||||
selectedTextChannel: '',
|
|
||||||
activeTab: 'voice',
|
|
||||||
text: [],
|
|
||||||
isStreaming: false,
|
|
||||||
isListening: false,
|
|
||||||
localStreaming: false,
|
|
||||||
localListening: false,
|
|
||||||
mediaAutoListening: false,
|
|
||||||
audioContextTransmit: null,
|
|
||||||
audioContextListen: null,
|
|
||||||
processor: null,
|
|
||||||
userTimelines: new Map(),
|
|
||||||
applyingServerState: false,
|
|
||||||
media: { playing: false, current: null, queue: [] },
|
|
||||||
};
|
|
||||||
const SAMPLE_RATE = 24000;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const el = {
|
|
||||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
|
|
||||||
};
|
|
||||||
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
|
||||||
const bars = [...document.querySelectorAll('.bar')];
|
|
||||||
|
|
||||||
async function apiRequest(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || response.statusText); } return response.json(); }
|
|
||||||
function showError(message) { el.errorBox.textContent = message; el.errorBox.style.display = 'block'; setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); }
|
|
||||||
async function postUIState(patch) { const next = await apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify(patch) }); await applyServerState(next); return next; }
|
|
||||||
function renderOptions(select, items, placeholder) { select.replaceChildren(); const first = document.createElement('option'); first.value = ''; first.textContent = placeholder; select.appendChild(first); for (const item of items) { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
|
||||||
function appendOptions(select, items) { const existing = new Set([...select.options].map((option) => option.value)); for (const item of items) { if (existing.has(item.id)) continue; const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; select.appendChild(option); } }
|
|
||||||
function appendEmpty(parent, message) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = message; parent.appendChild(empty); }
|
|
||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
|
|
||||||
async function loadVoiceChannels(guildId) { if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; }
|
|
||||||
async function loadTextChannels(guildId) { if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
|
||||||
async function connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
|
||||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
|
||||||
|
|
||||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
|
||||||
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; reconcileDynamicAudio().catch((error) => showError(error.message)); renderMedia(); } }
|
|
||||||
|
|
||||||
async function applyServerState(next) {
|
|
||||||
if (!next || state.applyingServerState) return;
|
|
||||||
state.applyingServerState = true;
|
|
||||||
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
|
||||||
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
|
||||||
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
|
||||||
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
|
||||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
|
||||||
state.selectedVoiceGuild = nextVoiceGuild;
|
|
||||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
|
||||||
state.selectedTextGuild = nextTextGuild;
|
|
||||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
|
||||||
state.activeTab = next.activeTab || 'voice';
|
|
||||||
state.isListening = !!next.isListening;
|
|
||||||
state.isStreaming = !!next.isStreaming;
|
|
||||||
el.voiceGuildSelect.value = state.selectedVoiceGuild;
|
|
||||||
el.textGuildSelect.value = state.selectedTextGuild;
|
|
||||||
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
|
|
||||||
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
|
|
||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
|
||||||
applyActiveTab(state.activeTab);
|
|
||||||
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
|
|
||||||
apiRequest('/api/backlog-sync', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
|
|
||||||
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
|
|
||||||
}
|
|
||||||
if (textChanged || textGuildChanged || state.activeTab === 'text') {
|
|
||||||
fetchText().catch((error) => showError(error.message));
|
|
||||||
}
|
|
||||||
await reconcileDynamicAudio();
|
|
||||||
state.applyingServerState = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyActiveTab(tab) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.toggle('active', item.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach((item) => item.classList.toggle('active', item.id === tab)); el.activeTabLabel.textContent = tab === 'text' ? 'Text' : 'Voice'; }
|
|
||||||
async function reconcileDynamicAudio() { await reconcileStreamingState(); await reconcileListenState(); }
|
|
||||||
async function reconcileListenState() { const shouldListen = state.isListening || !!state.media.current; if (shouldListen && !state.localListening) { try { await startListeningLocal(!!state.media.current && !state.isListening); } catch (error) { showError(`Speaker error: ${error.message}`); state.isListening = false; state.mediaAutoListening = false; stopListeningLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isListening: false }) }).catch((postError) => showError(postError.message)); } } else if (!shouldListen && state.localListening) { stopListeningLocal(); } else if (state.localListening) { renderListenStatus(); } }
|
|
||||||
async function reconcileStreamingState() { if (state.media.current && state.isStreaming) { state.isStreaming = false; apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isStreaming: false }) }).catch((postError) => showError(postError.message)); } if (state.isStreaming && !state.localStreaming) { try { await startStreamingLocal(); } catch (error) { showError(`Microphone error: ${error.message}`); state.isStreaming = false; stopStreamingLocal(); apiRequest('/api/ui-state', { method: 'POST', body: JSON.stringify({ isStreaming: false }) }).catch((postError) => showError(postError.message)); } } else if (!state.isStreaming && state.localStreaming) { stopStreamingLocal(); } }
|
|
||||||
|
|
||||||
function renderUsers(users) { el.userList.replaceChildren(); if (users.length === 0) return appendEmpty(el.userList, 'No active speakers'); for (const user of users) { const row = document.createElement('div'); row.className = `user-item${user.speaking ? ' speaking' : ''}`; const img = document.createElement('img'); img.src = user.avatar || ''; img.alt = ''; const name = document.createElement('span'); name.textContent = user.username; row.append(img, name); el.userList.appendChild(row); } }
|
|
||||||
async function fetchText() { if (!state.selectedTextChannel) return renderText(); const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedTextChannel)}&type=text&limit=80`); state.text = result.data || []; renderText(); }
|
|
||||||
function renderText() { el.textList.replaceChildren(); el.reviewList.replaceChildren(); if (!state.selectedTextChannel) { appendEmpty(el.textList, 'Select channel to view text captures'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } if (state.text.length === 0) { appendEmpty(el.textList, 'No text captures yet'); appendEmpty(el.reviewList, 'No warned or flagged messages'); return; } const reviewMessages = []; for (const msg of state.text) { const metadata = parseMetadata(msg.metadata); const card = document.createElement('article'); card.className = 'event-card'; const head = document.createElement('div'); head.className = 'event-head'; const author = document.createElement('div'); author.className = 'author'; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (msg.avatar_url) { const img = document.createElement('img'); img.src = msg.avatar_url; img.alt = ''; avatar.appendChild(img); } const name = document.createElement('div'); name.className = 'name'; name.textContent = msg.username || msg.user_id; author.append(avatar, name); const time = document.createElement('div'); time.className = 'time'; time.textContent = new Date(msg.created_at).toLocaleString(); head.append(author, time); const text = document.createElement('div'); text.className = 'message-text'; text.textContent = msg.edited_content || msg.content || '(empty message)'; card.append(head, text); appendAIAnalysis(card, msg); appendMedia(card, metadata); const badges = document.createElement('div'); badges.className = 'badges'; if (metadata.reference?.messageId) appendBadge(badges, 'reply', ''); if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', ''); if (msg.edited_at) appendBadge(badges, 'edited', 'edit'); if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete'); card.appendChild(badges); el.textList.appendChild(card); if (msg.ai_status === 'warn' || msg.ai_status === 'flagged') reviewMessages.push(card.cloneNode(true)); } if (reviewMessages.length === 0) { appendEmpty(el.reviewList, 'No warned or flagged messages'); } else { reviewMessages.forEach((card) => el.reviewList.appendChild(card)); } }
|
|
||||||
function appendAIAnalysis(card, msg) { const status = msg.ai_status || 'pending'; const wrap = document.createElement('div'); wrap.className = 'badges'; const badge = document.createElement('span'); badge.className = `badge ${status === 'flagged' ? 'delete' : status === 'clean' ? 'edit' : ''}`; badge.textContent = `AI: ${status}`; wrap.appendChild(badge); if (msg.ai_moderation_flags) { const flags = document.createElement('span'); flags.className = 'badge delete'; try { flags.textContent = JSON.parse(msg.ai_moderation_flags).join(', '); } catch { flags.textContent = msg.ai_moderation_flags; } wrap.appendChild(flags); } card.appendChild(wrap); if (msg.ai_analysis) { const analysis = document.createElement('div'); analysis.className = 'embed-description'; analysis.textContent = msg.ai_analysis; card.appendChild(analysis); } if (msg.ai_error) { const error = document.createElement('div'); error.className = 'embed-description'; error.textContent = `AI error: ${msg.ai_error}`; card.appendChild(error); } }
|
|
||||||
function appendMedia(card, metadata) { const stickers = document.createElement('div'); stickers.className = 'sticker-strip'; for (const sticker of metadata.stickers || []) { const img = document.createElement('img'); img.className = 'sticker-img'; img.src = sticker.url; img.alt = sticker.name; stickers.appendChild(img); } if (stickers.childElementCount) card.appendChild(stickers); const embeds = document.createElement('div'); embeds.className = 'feed'; for (const embed of metadata.embeds || []) { const item = document.createElement('div'); item.className = 'embed-card'; if (embed.title) { const title = document.createElement(embed.url ? 'a' : 'div'); title.className = 'embed-title'; title.textContent = embed.title; if (embed.url) { title.href = embed.url; title.target = '_blank'; title.rel = 'noreferrer'; } item.appendChild(title); } if (embed.description) { const desc = document.createElement('div'); desc.className = 'embed-description'; desc.textContent = embed.description; item.appendChild(desc); } if (embed.image || embed.thumbnail) { const img = document.createElement('img'); img.className = 'embed-image'; img.src = embed.image || embed.thumbnail; img.alt = embed.title || 'embed image'; item.appendChild(img); } embeds.appendChild(item); } if (embeds.childElementCount) card.appendChild(embeds); const attachments = document.createElement('div'); attachments.className = 'attachment-strip'; for (const attachment of metadata.attachments || []) { const link = document.createElement('a'); link.className = 'attachment-chip'; link.href = attachment.url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; attachments.appendChild(link); } if (attachments.childElementCount) card.appendChild(attachments); }
|
|
||||||
|
|
||||||
function handleIncomingPCM(data) { if (!state.localListening || !state.audioContextListen) return; const headerView = new DataView(data, 0, 4); const userIdHash = headerView.getInt32(0, true); const audioData = data.slice(4); const int16Array = new Int16Array(audioData); const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE); const nowBuffering = audioBuffer.getChannelData(0); for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i]; const source = state.audioContextListen.createBufferSource(); source.buffer = audioBuffer; source.connect(state.audioContextListen.destination); const currentTime = state.audioContextListen.currentTime; let userNextStartTime = state.userTimelines.get(userIdHash) || 0; if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05; source.start(userNextStartTime); userNextStartTime += audioBuffer.duration; state.userTimelines.set(userIdHash, userNextStartTime); }
|
|
||||||
async function startStreamingLocal() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.localStreaming = true; el.toggleBtn.textContent = 'Stop Transmitting'; state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); const source = state.audioContextTransmit.createMediaStreamSource(stream); const analyser = state.audioContextTransmit.createAnalyser(); analyser.fftSize = 64; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1); source.connect(state.processor); state.processor.connect(state.audioContextTransmit.destination); state.processor.onaudioprocess = (event) => { if (!state.localStreaming || state.socket.readyState !== WebSocket.OPEN) return; const inputData = event.inputBuffer.getChannelData(0); const pcmData = new Int16Array(inputData.length); for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; state.socket.send(pcmData.buffer); analyser.getByteFrequencyData(dataArray); bars.forEach((bar, index) => { const percent = (dataArray[index] / 255) * 100; bar.style.height = `${Math.max(2, percent)}%`; }); }; }
|
|
||||||
function stopStreamingLocal() { state.localStreaming = false; if (state.processor) state.processor.disconnect(); if (state.audioContextTransmit) state.audioContextTransmit.close(); state.processor = null; state.audioContextTransmit = null; el.toggleBtn.textContent = 'Start Transmitting'; bars.forEach((bar) => { bar.style.height = '2px'; }); }
|
|
||||||
async function startListeningLocal(auto = false) { if (!state.audioContextListen) state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }); await state.audioContextListen.resume(); state.localListening = true; state.mediaAutoListening = auto; renderListenStatus(); }
|
|
||||||
function stopListeningLocal() { state.audioContextListen?.suspend(); state.userTimelines.clear(); state.localListening = false; state.mediaAutoListening = false; renderListenStatus(); }
|
|
||||||
function renderListenStatus() { el.listenBtn.textContent = state.isListening ? 'Stop Listening' : 'Join Listen Channel'; el.listenStatus.textContent = state.localListening ? (state.media.current && state.mediaAutoListening ? 'Media Monitor On' : 'Listening Live...') : 'Speaker Off'; }
|
|
||||||
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
|
||||||
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
|
|
||||||
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
|
||||||
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
|
||||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
|
||||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
|
||||||
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
|
||||||
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
|
||||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
|
||||||
|
|
||||||
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
|
|
||||||
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); if (state.isStreaming || state.localStreaming) await postUIState({ isStreaming: false }); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; await reconcileDynamicAudio(); renderMedia(); }
|
|
||||||
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); await reconcileDynamicAudio(); renderMedia(); }
|
|
||||||
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); await reconcileDynamicAudio(); renderMedia(); }
|
|
||||||
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
|
|
||||||
|
|
||||||
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
|
|
||||||
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
|
|
||||||
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
|
|
||||||
|
|
||||||
connectWebSocket();
|
|
||||||
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
|
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -76,6 +76,7 @@ const configSchema = z
|
|||||||
POSTGRES_DB: z.string().optional(),
|
POSTGRES_DB: z.string().optional(),
|
||||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||||
|
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (!value.AI_ANALYSIS_ENABLED) {
|
if (!value.AI_ANALYSIS_ENABLED) {
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ let db:
|
|||||||
*/
|
*/
|
||||||
export async function initializeDatabase() {
|
export async function initializeDatabase() {
|
||||||
if (db !== null) {
|
if (db !== null) {
|
||||||
logger.debug("Database already initialized, skipping");
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.DATABASE_TYPE === "postgres") {
|
// During tests prefer an isolated SQLite instance to avoid using shared
|
||||||
|
// external Postgres instances which can lead to flaky test interference.
|
||||||
|
const usePostgres =
|
||||||
|
config.DATABASE_TYPE === "postgres" && process.env.NODE_ENV !== "test";
|
||||||
|
|
||||||
|
if (usePostgres) {
|
||||||
let pool: Pool;
|
let pool: Pool;
|
||||||
|
|
||||||
// Use DATABASE_URL if available, otherwise build from individual variables
|
// Use DATABASE_URL if available, otherwise build from individual variables
|
||||||
@@ -46,12 +50,25 @@ export async function initializeDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db = drizzlePostgres(pool, { schema });
|
db = drizzlePostgres(pool, { schema });
|
||||||
|
// Provide a simple `run` helper for tests that expect it.
|
||||||
|
try {
|
||||||
|
(db as any).run = (sql: string) => pool.query(sql);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
logger.info("PostgreSQL database initialized");
|
logger.info("PostgreSQL database initialized");
|
||||||
} else {
|
} else {
|
||||||
const sqlite = new Database(".muxer-queue.db");
|
const sqlite = new Database(".muxer-queue.db");
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
db = drizzleSqlite(sqlite, { schema });
|
db = drizzleSqlite(sqlite, { schema });
|
||||||
|
// Expose a convenience `run` method used by tests that expect a simple API.
|
||||||
|
// `sqlite` is the underlying better-sqlite3 Database instance.
|
||||||
|
try {
|
||||||
|
(db as any).run = (sql: string) => sqlite.exec(sql);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
logger.info("SQLite database initialized");
|
logger.info("SQLite database initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ async function initializeApp() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.on("debug", (msg) => {
|
||||||
|
if (msg.includes("[VOICE") || msg.includes("[ffmpeg") || msg.toLowerCase().includes("error") || msg.toLowerCase().includes("stream")) {
|
||||||
|
logger.info({ debugMsg: msg }, "Discord Client Debug");
|
||||||
|
} else if (config.VERBOSE) {
|
||||||
|
logger.debug({ debugMsg: msg }, "Discord Client Debug");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { createMusicPlayer } from "./musicPlayer";
|
|||||||
export interface MediaControllerDependencies {
|
export interface MediaControllerDependencies {
|
||||||
isVoiceConnected?: () => boolean;
|
isVoiceConnected?: () => boolean;
|
||||||
isBrowserStreaming?: () => boolean;
|
isBrowserStreaming?: () => boolean;
|
||||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
||||||
musicPlayer?: MusicPlayer;
|
musicPlayer?: MusicPlayer;
|
||||||
screenController?: ScreenShareController;
|
screenController?: ScreenShareController;
|
||||||
onStateChange?: (state: MediaState) => void;
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
initialMusicVolume?: number;
|
||||||
|
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||||
|
setMusicVolume?: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
@@ -31,9 +34,18 @@ export class MediaController {
|
|||||||
private skipInProgress = false;
|
private skipInProgress = false;
|
||||||
private screenPlayback: ScreenSharePlayback | null = null;
|
private screenPlayback: ScreenSharePlayback | null = null;
|
||||||
private activeMode: MediaMode | null = null;
|
private activeMode: MediaMode | null = null;
|
||||||
|
private musicVolume: number;
|
||||||
|
private readonly setPlayerMusicVolume: (volume: number) => void;
|
||||||
|
|
||||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||||
|
this.setPlayerMusicVolume =
|
||||||
|
dependencies.setMusicVolume ??
|
||||||
|
((volume) => {
|
||||||
|
discordPlayer.setMusicVolume(volume);
|
||||||
|
});
|
||||||
|
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
|
||||||
|
this.setPlayerMusicVolume(this.musicVolume);
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): MediaState {
|
getState(): MediaState {
|
||||||
@@ -42,23 +54,52 @@ export class MediaController {
|
|||||||
playing:
|
playing:
|
||||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||||
|
musicVolume: this.musicVolume,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setMusicVolume(volume: number): Promise<MediaState> {
|
||||||
|
const nextVolume = normalizeVolume(volume, this.musicVolume);
|
||||||
|
if (this.musicVolume === nextVolume) return this.emitState();
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
this.setPlayerMusicVolume(nextVolume);
|
||||||
|
await this.dependencies.onMusicVolumeChange?.(nextVolume);
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
async queue(
|
async queue(
|
||||||
source: string,
|
source: string,
|
||||||
options: QueueMediaOptions = {},
|
options: QueueMediaOptions = {},
|
||||||
): Promise<MediaState> {
|
): Promise<MediaState> {
|
||||||
const mode = options.mode ?? "music";
|
const mode = options.mode ?? "music";
|
||||||
|
|
||||||
|
const resolved = await (
|
||||||
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||||
|
)(source, mode);
|
||||||
|
|
||||||
if (mode === "screen") {
|
if (mode === "screen") {
|
||||||
return this.startScreen(source);
|
// Stop current music if any
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
return this.startScreen(resolved.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === "music"
|
||||||
|
// If a screen share is active outside of this controller (browser-owned),
|
||||||
|
// reject to avoid stealing the shared player. If this controller started
|
||||||
|
// the screenPlayback, stop it and proceed.
|
||||||
|
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
||||||
|
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
|
||||||
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||||
|
}
|
||||||
|
this.screenPlayback?.stop();
|
||||||
|
this.screenPlayback = null;
|
||||||
|
this.activeMode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertCanStartMusic();
|
this.assertCanStartMusic();
|
||||||
const resolved = await (
|
|
||||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
|
||||||
)(source);
|
|
||||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||||
this.startNextIfIdle();
|
this.startNextIfIdle();
|
||||||
return this.emitState();
|
return this.emitState();
|
||||||
@@ -108,10 +149,6 @@ export class MediaController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
|
||||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dependencies.isBrowserStreaming?.()) {
|
if (this.dependencies.isBrowserStreaming?.()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"Stop browser microphone streaming before playing media",
|
"Stop browser microphone streaming before playing media",
|
||||||
@@ -122,14 +159,6 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startScreen(source: string): Promise<MediaState> {
|
private async startScreen(source: string): Promise<MediaState> {
|
||||||
if (
|
|
||||||
this.screenPlayback ||
|
|
||||||
this.dependencies.screenController?.isActive() ||
|
|
||||||
this.playback ||
|
|
||||||
this.queueStore.snapshot().current
|
|
||||||
) {
|
|
||||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
|
||||||
}
|
|
||||||
const screenController = this.dependencies.screenController;
|
const screenController = this.dependencies.screenController;
|
||||||
if (!screenController) {
|
if (!screenController) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -201,3 +230,8 @@ export class MediaController {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVolume(value: number | undefined, fallback: number): number {
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(0, Math.min(1, value as number));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { ResolvedMediaSource } from "./mediaTypes";
|
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
|
||||||
import { createPlayDlResolver } from "./playDlResolver";
|
import { createPlayDlResolver } from "./playDlResolver";
|
||||||
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ export function createMediaResolver(
|
|||||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
||||||
|
|
||||||
return async function resolve(input: string): Promise<ResolvedMediaSource> {
|
return async function resolve(
|
||||||
|
input: string,
|
||||||
|
mode: MediaMode = "music"
|
||||||
|
): Promise<ResolvedMediaSource> {
|
||||||
const source = input.trim();
|
const source = input.trim();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -31,13 +34,17 @@ export function createMediaResolver(
|
|||||||
const url = parseUrl(source);
|
const url = parseUrl(source);
|
||||||
if (url && isYouTubeUrl(url)) {
|
if (url && isYouTubeUrl(url)) {
|
||||||
const metadata = await ytdlp.getMetadata(source);
|
const metadata = await ytdlp.getMetadata(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(source);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(source)
|
||||||
|
: await ytdlp.getDirectAudioUrl(source);
|
||||||
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url && isSpotifyTrackUrl(url)) {
|
if (url && isSpotifyTrackUrl(url)) {
|
||||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
const result = await playDlResolver.resolveSpotifyTrack(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(result.url)
|
||||||
|
: await ytdlp.getDirectAudioUrl(result.url);
|
||||||
return { source: directUrl, title: result.title, kind: "spotify" };
|
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +62,9 @@ export function createMediaResolver(
|
|||||||
|
|
||||||
if (!url && !looksLikeUrl(source)) {
|
if (!url && !looksLikeUrl(source)) {
|
||||||
const result = await playDlResolver.searchYouTube(source);
|
const result = await playDlResolver.searchYouTube(source);
|
||||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
const directUrl = mode === "screen"
|
||||||
|
? await ytdlp.getDirectVideoUrl(result.url)
|
||||||
|
: await ytdlp.getDirectAudioUrl(result.url);
|
||||||
return { source: directUrl, title: result.title, kind: "search" };
|
return { source: directUrl, title: result.title, kind: "search" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
export type MediaMode = "music" | "screen";
|
export type MediaMode = "music" | "screen";
|
||||||
export type MediaSourceKind =
|
export type MediaSourceKind =
|
||||||
@@ -26,6 +27,7 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
|||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
activeMode: MediaMode | null;
|
activeMode: MediaMode | null;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
@@ -56,11 +58,23 @@ export interface ScreenShareController {
|
|||||||
|
|
||||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||||
|
|
||||||
|
export interface DiscordPlayOptions {
|
||||||
|
inputType?: StreamType;
|
||||||
|
inlineVolume?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscordAudioPlayer {
|
export interface DiscordAudioPlayer {
|
||||||
getOwner(): DiscordPlayerOwner;
|
getOwner(): DiscordPlayerOwner;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
|
playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options?: DiscordPlayOptions,
|
||||||
|
): void;
|
||||||
pause(owner?: DiscordPlayerOwner): void;
|
pause(owner?: DiscordPlayerOwner): void;
|
||||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||||
stop(owner?: DiscordPlayerOwner): void;
|
stop(owner?: DiscordPlayerOwner): void;
|
||||||
|
getMusicVolume(): number;
|
||||||
|
setMusicVolume(volume: number): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
@@ -30,7 +31,10 @@ export function createMusicPlayer(
|
|||||||
}) as unknown as ChildProcessWithoutNullStreams;
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
proc.stderr.resume();
|
proc.stderr.resume();
|
||||||
|
|
||||||
audioPlayer.playStream(proc.stdout, "music");
|
audioPlayer.playStream(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let released = false;
|
let released = false;
|
||||||
@@ -81,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
|
|||||||
source,
|
source,
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Readable } from "node:stream";
|
|
||||||
import {
|
import {
|
||||||
playStream as defaultPlayStream,
|
Streamer,
|
||||||
prepareStream as defaultPrepareStream,
|
playPreparedStream,
|
||||||
Encoders,
|
} from "../streaming";
|
||||||
Utils,
|
|
||||||
} from "@dank074/discord-video-stream";
|
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
|
|
||||||
|
const logger = createChildLogger("screen-share");
|
||||||
|
|
||||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||||
import { createYtDlp } from "./ytdlp";
|
import { createYtDlp } from "./ytdlp";
|
||||||
|
|
||||||
@@ -16,29 +17,16 @@ export interface ScreenShareVoiceStatus {
|
|||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedScreenStream {
|
|
||||||
command: { kill?: (signal: NodeJS.Signals) => unknown };
|
|
||||||
output: Readable;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrepareScreenStream = (
|
|
||||||
source: string,
|
|
||||||
options: object,
|
|
||||||
) => PreparedScreenStream;
|
|
||||||
|
|
||||||
type PlayScreenStream = (
|
|
||||||
output: Readable,
|
|
||||||
streamer: unknown,
|
|
||||||
options: { type: "go-live" },
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
export interface ScreenShareControllerDependencies {
|
export interface ScreenShareControllerDependencies {
|
||||||
getVoiceStatus: () => ScreenShareVoiceStatus;
|
getVoiceStatus: () => ScreenShareVoiceStatus;
|
||||||
getPlayerOwner?: () => DiscordPlayerOwner;
|
getPlayerOwner?: () => DiscordPlayerOwner;
|
||||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||||
prepareStream?: PrepareScreenStream;
|
streamer: Streamer;
|
||||||
playStream?: PlayScreenStream;
|
useTranscoder?: boolean;
|
||||||
streamer: unknown;
|
onBeforeStreamStart?: (guildId: string, channelId: string) => Promise<void> | void;
|
||||||
|
onAfterStreamEnd?: (guildId: string, channelId: string) => Promise<void> | void;
|
||||||
|
onStreamStart?: () => void;
|
||||||
|
onStreamEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createScreenShareController(
|
export function createScreenShareController(
|
||||||
@@ -51,12 +39,6 @@ export function createScreenShareController(
|
|||||||
const getDirectVideoUrl =
|
const getDirectVideoUrl =
|
||||||
dependencies.getDirectVideoUrl ??
|
dependencies.getDirectVideoUrl ??
|
||||||
((source) => ytdlp.getDirectVideoUrl(source));
|
((source) => ytdlp.getDirectVideoUrl(source));
|
||||||
const prepareStream =
|
|
||||||
dependencies.prepareStream ??
|
|
||||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
|
||||||
const playStream =
|
|
||||||
dependencies.playStream ??
|
|
||||||
(defaultPlayStream as unknown as PlayScreenStream);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
@@ -65,6 +47,20 @@ export function createScreenShareController(
|
|||||||
|
|
||||||
async start(source: string): Promise<ScreenSharePlayback> {
|
async start(source: string): Promise<ScreenSharePlayback> {
|
||||||
const status = dependencies.getVoiceStatus();
|
const status = dependencies.getVoiceStatus();
|
||||||
|
let voiceReleased = false;
|
||||||
|
let voiceRestored = false;
|
||||||
|
|
||||||
|
const restoreVoice = async () => {
|
||||||
|
if (voiceRestored || !voiceReleased || !guildId || !channelId) return;
|
||||||
|
voiceRestored = true;
|
||||||
|
await dependencies.onAfterStreamEnd?.(guildId, channelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
active.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bot is in the voice channel and owns the screen-share stream
|
||||||
if (
|
if (
|
||||||
!status.connected ||
|
!status.connected ||
|
||||||
!status.activeGuildId ||
|
!status.activeGuildId ||
|
||||||
@@ -77,41 +73,81 @@ export function createScreenShareController(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active || getPlayerOwner() !== "none") {
|
const guildId = status.activeGuildId;
|
||||||
|
const channelId = status.activeChannelId;
|
||||||
|
|
||||||
|
// If another media owner (e.g. music) holds the shared player, reject
|
||||||
|
const owner = getPlayerOwner();
|
||||||
|
if (owner === "music") {
|
||||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const directUrl = await getDirectVideoUrl(source);
|
const directUrl = await getDirectVideoUrl(source);
|
||||||
const { command, output } = prepareStream(directUrl, {
|
logger.info(
|
||||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
{
|
||||||
height: 720,
|
guildId,
|
||||||
frameRate: 30,
|
channelId,
|
||||||
bitrateVideo: 2500,
|
},
|
||||||
bitrateVideoMax: 4000,
|
"Creating screen share session",
|
||||||
includeAudio: true,
|
);
|
||||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
await dependencies.onBeforeStreamStart?.(guildId, channelId);
|
||||||
});
|
voiceReleased = true;
|
||||||
|
const session = await dependencies.streamer.createSession(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dependencies.onStreamStart?.();
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const done = playStream(output, dependencies.streamer, {
|
const playFn = dependencies.useTranscoder
|
||||||
type: "go-live",
|
? (await import("../streaming")).playTranscodedPreparedStream
|
||||||
|
: (await import("../streaming")).playPreparedStream;
|
||||||
|
|
||||||
|
const done = playFn(directUrl, session, {
|
||||||
|
fps: 30,
|
||||||
|
bitrate: 2500,
|
||||||
|
includeAudio: true,
|
||||||
|
presetH26x: "superfast",
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
active = null;
|
active = null;
|
||||||
|
dependencies.onStreamEnd?.();
|
||||||
|
return restoreVoice();
|
||||||
});
|
});
|
||||||
|
done.catch(() => undefined);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
},
|
||||||
|
"Screen share session started",
|
||||||
|
);
|
||||||
|
|
||||||
active = {
|
active = {
|
||||||
done,
|
done,
|
||||||
stop() {
|
stop() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
stopped = true;
|
stopped = true;
|
||||||
command.kill?.("SIGTERM");
|
session.stop();
|
||||||
active = null;
|
active = null;
|
||||||
|
void restoreVoice();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return active;
|
return active;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
active = null;
|
active = null;
|
||||||
|
if (voiceReleased) {
|
||||||
|
await restoreVoice();
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
},
|
||||||
|
"Screen share startup failed",
|
||||||
|
);
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
error instanceof Error ? error.message : "Screen stream failed",
|
error instanceof Error ? error.message : "Screen stream failed",
|
||||||
"SCREEN_STREAM_FAILED",
|
"SCREEN_STREAM_FAILED",
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
|||||||
url,
|
url,
|
||||||
"--get-url",
|
"--get-url",
|
||||||
"--format",
|
"--format",
|
||||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
"best[protocol^=http]/best",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
]);
|
]);
|
||||||
return value.trim().split("\n")[0] || url;
|
return value.trim();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ export const wsMessagesCounter = new Counter({
|
|||||||
labelNames: ["message_type"],
|
labelNames: ["message_type"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transcoder metrics
|
||||||
|
export const transcoderRestartsCounter = new Counter({
|
||||||
|
name: "transcoder_restarts_total",
|
||||||
|
help: "Total number of transcoder restarts",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transcoderRunningGauge = new Gauge({
|
||||||
|
name: "transcoder_running",
|
||||||
|
help: "Whether a transcoder process is currently running (1/0)",
|
||||||
|
});
|
||||||
|
|
||||||
// HTTP metrics
|
// HTTP metrics
|
||||||
export const httpRequestDurationHistogram = new Histogram({
|
export const httpRequestDurationHistogram = new Histogram({
|
||||||
name: "http_request_duration_seconds",
|
name: "http_request_duration_seconds",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||||
const CRC_TABLE = new Uint32Array(256);
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
|
|||||||
return originalRequire.apply(this, arguments);
|
return originalRequire.apply(this, arguments);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[mock] node-crc has been mocked globally.");
|
console.log("[mock] node-crc has been mocked globally for ESM.");
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parentPort } from "node:worker_threads";
|
import { parentPort } from "node:worker_threads";
|
||||||
|
import { initializeDatabase } from "../database/drizzle";
|
||||||
import { buildConversationPromptMessages } from "./conversationContext";
|
import { buildConversationPromptMessages } from "./conversationContext";
|
||||||
import { runModerationAnalysis } from "./llmModerationClient";
|
import { runModerationAnalysis } from "./llmModerationClient";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,8 @@ import type { MessageRecord } from "./types";
|
|||||||
|
|
||||||
const MAX_CONTEXT_TOKENS = 8000;
|
const MAX_CONTEXT_TOKENS = 8000;
|
||||||
|
|
||||||
|
let dbInitialized = false;
|
||||||
|
|
||||||
interface AnalysisWorkerRequest {
|
interface AnalysisWorkerRequest {
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
@@ -32,6 +35,21 @@ async function processAnalysisRequest({
|
|||||||
messages,
|
messages,
|
||||||
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
if (!dbInitialized) {
|
||||||
|
await initializeDatabase();
|
||||||
|
dbInitialized = true;
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
const msg = dbError instanceof Error ? dbError.message : String(dbError);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
conversationKey,
|
||||||
|
rows: [],
|
||||||
|
error: `Database init failed: ${msg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const firstMessage = messages[0];
|
const firstMessage = messages[0];
|
||||||
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -26,35 +26,43 @@ export function parseModerationResponse(
|
|||||||
content: string,
|
content: string,
|
||||||
targetIds: string[],
|
targetIds: string[],
|
||||||
): AnalysisResult[] {
|
): AnalysisResult[] {
|
||||||
// Find first opening brace
|
// Find first opening brace and last closing brace
|
||||||
const startIdx = content.indexOf("{");
|
const startIdx = content.indexOf("{");
|
||||||
if (startIdx === -1) {
|
const endIdx = content.lastIndexOf("}");
|
||||||
|
|
||||||
|
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
||||||
throw new Error("No JSON object found in response");
|
throw new Error("No JSON object found in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan from start and try parsing at each closing brace
|
// Attempt to parse the largest possible JSON object
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
let lastError: Error | null = null;
|
const candidate = content.substring(startIdx, endIdx + 1);
|
||||||
|
|
||||||
for (let i = startIdx + 1; i < content.length; i++) {
|
|
||||||
if (content[i] === "}") {
|
|
||||||
const candidate = content.substring(startIdx, i + 1);
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(candidate);
|
parsed = JSON.parse(candidate);
|
||||||
// Successfully parsed, break out
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Store error and continue scanning
|
// If full substring fails, try scanning backwards from the last }
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
let lastError: Error =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
for (let i = endIdx - 1; i > startIdx; i--) {
|
||||||
|
if (content[i] === "}") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content.substring(startIdx, i + 1));
|
||||||
|
break;
|
||||||
|
} catch (innerError) {
|
||||||
|
lastError =
|
||||||
|
innerError instanceof Error
|
||||||
|
? innerError
|
||||||
|
: new Error(String(innerError));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new Error(
|
throw new Error(`Failed to parse JSON: ${lastError.message}`);
|
||||||
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`,
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
@@ -72,7 +80,7 @@ export function parseModerationResponse(
|
|||||||
const targetIdSet = new Set(targetIds);
|
const targetIdSet = new Set(targetIds);
|
||||||
|
|
||||||
// Parse and validate each result
|
// Parse and validate each result
|
||||||
const results: AnalysisResult[] = response.results.map((result) => {
|
const results: (AnalysisResult | null)[] = response.results.map((result) => {
|
||||||
const { message_id, status, flags, score, analysis } = result;
|
const { message_id, status, flags, score, analysis } = result;
|
||||||
|
|
||||||
// Validate message_id exists and is in target list
|
// Validate message_id exists and is in target list
|
||||||
@@ -80,15 +88,36 @@ export function parseModerationResponse(
|
|||||||
throw new Error("Result missing 'message_id'");
|
throw new Error("Result missing 'message_id'");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetIdSet.has(message_id)) {
|
let finalId = String(message_id);
|
||||||
throw new Error(`Unknown message_id: ${message_id}`);
|
|
||||||
|
// Precision loss fix: If the ID from LLM is not found,
|
||||||
|
// try to find the closest match in targets if it looks rounded (ends in 000)
|
||||||
|
if (!targetIdSet.has(finalId)) {
|
||||||
|
if (finalId.endsWith("00") || finalId.includes("e+")) {
|
||||||
|
const roundedPrefix = finalId.substring(0, 10);
|
||||||
|
const match = targetIds.find((id) => id.startsWith(roundedPrefix));
|
||||||
|
if (match) {
|
||||||
|
log.warn(
|
||||||
|
{ roundedId: finalId, matchedId: match },
|
||||||
|
"Fixed precision loss in message ID",
|
||||||
|
);
|
||||||
|
finalId = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundIds.has(message_id)) {
|
if (!targetIdSet.has(finalId)) {
|
||||||
throw new Error(`Duplicate message_id in results: ${message_id}`);
|
throw new Error(
|
||||||
|
`Unknown message_id: ${finalId} (original: ${message_id})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
foundIds.add(message_id);
|
if (foundIds.has(finalId)) {
|
||||||
|
log.warn({ duplicateId: finalId }, "Duplicate message_id in response");
|
||||||
|
throw new Error(`Duplicate message_id: ${finalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIds.add(finalId);
|
||||||
|
|
||||||
// Validate status
|
// Validate status
|
||||||
const validStatuses = ["clean", "warn", "flagged"] as const;
|
const validStatuses = ["clean", "warn", "flagged"] as const;
|
||||||
@@ -120,7 +149,7 @@ export function parseModerationResponse(
|
|||||||
const analysisStr = analysis ? String(analysis) : "";
|
const analysisStr = analysis ? String(analysis) : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: message_id,
|
messageId: finalId,
|
||||||
status: status as "clean" | "warn" | "flagged",
|
status: status as "clean" | "warn" | "flagged",
|
||||||
flags: flagsArray,
|
flags: flagsArray,
|
||||||
score: numScore,
|
score: numScore,
|
||||||
@@ -128,13 +157,18 @@ export function parseModerationResponse(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredResults = results.filter(
|
||||||
|
(r): r is AnalysisResult => r !== null,
|
||||||
|
);
|
||||||
|
|
||||||
// Check that all target IDs were found
|
// Check that all target IDs were found
|
||||||
const missingIds = targetIds.filter((id) => !foundIds.has(id));
|
const missingIds = targetIds.filter((id) => !foundIds.has(id));
|
||||||
if (missingIds.length > 0) {
|
if (missingIds.length > 0) {
|
||||||
throw new Error(`Missing target ids in response: ${missingIds.join(", ")}`);
|
log.warn({ missingIds }, "Some target IDs missing in response");
|
||||||
|
throw new Error(`Missing target IDs: ${missingIds.join(",")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return filteredResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModerationInput {
|
interface ModerationInput {
|
||||||
@@ -174,8 +208,11 @@ Context: ${contextText}
|
|||||||
Messages to analyze:
|
Messages to analyze:
|
||||||
${messagesText}
|
${messagesText}
|
||||||
|
|
||||||
For each message, respond with a JSON object containing a "results" array. Each result must have:
|
For each message, respond with a JSON object containing a "results" array.
|
||||||
- message_id: the message ID
|
CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
|
||||||
|
|
||||||
|
Each result must have:
|
||||||
|
- message_id: the message ID (STRING, exactly as provided)
|
||||||
- status: "clean", "warn", or "flagged"
|
- status: "clean", "warn", or "flagged"
|
||||||
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
|
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
|
||||||
- score: confidence score from 0 to 1
|
- score: confidence score from 0 to 1
|
||||||
@@ -213,13 +250,44 @@ Return ONLY valid JSON, no other text.`;
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Read the response body once (either text() or json()), then reuse it.
|
||||||
if (!response.ok) {
|
let rawBody: string | undefined = undefined;
|
||||||
const text = await response.text();
|
if (typeof response.text === "function") {
|
||||||
throw new Error(`LLM API error ${response.status}: ${text}`);
|
try {
|
||||||
|
rawBody = await response.text();
|
||||||
|
} catch {
|
||||||
|
rawBody = undefined;
|
||||||
|
}
|
||||||
|
} else if (typeof response.json === "function") {
|
||||||
|
try {
|
||||||
|
const j = await response.json();
|
||||||
|
rawBody = JSON.stringify(j);
|
||||||
|
} catch {
|
||||||
|
rawBody = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`LLM API error ${response.status}: ${rawBody ?? "(no body)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawBody) {
|
||||||
|
throw new Error("Empty LLM response");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the body as JSON, with fallback to scanning for an object
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody);
|
||||||
|
} catch (e) {
|
||||||
|
const start = rawBody.indexOf("{");
|
||||||
|
const end = rawBody.lastIndexOf("}");
|
||||||
|
if (start !== -1 && end !== -1 && end > start) {
|
||||||
|
return JSON.parse(rawBody.substring(start, end + 1));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface MediaQueueItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ import { Readable } from "node:stream";
|
|||||||
import {
|
import {
|
||||||
AudioPlayer,
|
AudioPlayer,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus,
|
||||||
|
type AudioResource,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
createAudioResource,
|
createAudioResource,
|
||||||
StreamType,
|
StreamType,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
import type { DiscordPlayerOwner } from "./media/mediaTypes";
|
import type {
|
||||||
|
DiscordPlayOptions,
|
||||||
|
DiscordPlayerOwner,
|
||||||
|
} from "./media/mediaTypes";
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
private connection: VoiceConnection | null = null;
|
private connection: VoiceConnection | null = null;
|
||||||
private owner: DiscordPlayerOwner = "none";
|
private owner: DiscordPlayerOwner = "none";
|
||||||
|
private resource: AudioResource | null = null;
|
||||||
|
private musicVolume = 1;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.player = createAudioPlayer();
|
this.player = createAudioPlayer();
|
||||||
@@ -24,6 +30,7 @@ export class DiscordPlayer {
|
|||||||
this.player.on("error", (error) => {
|
this.player.on("error", (error) => {
|
||||||
console.error(`[player] Error: ${error.message}`);
|
console.error(`[player] Error: ${error.message}`);
|
||||||
this.owner = "none";
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +47,34 @@ export class DiscordPlayer {
|
|||||||
return this.connection !== null;
|
return this.connection !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable, owner: DiscordPlayerOwner) {
|
public playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options: DiscordPlayOptions = {},
|
||||||
|
) {
|
||||||
if (owner === "none") {
|
if (owner === "none") {
|
||||||
throw new Error("Discord audio player owner is required");
|
throw new Error("Discord audio player owner is required");
|
||||||
}
|
}
|
||||||
this.assertOwnerAvailable(owner);
|
this.assertOwnerAvailable(owner);
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
const resource = createAudioResource(stream, {
|
||||||
inputType: StreamType.OggOpus,
|
inputType: options.inputType ?? StreamType.OggOpus,
|
||||||
|
inlineVolume: options.inlineVolume ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.owner === owner) {
|
if (this.owner === owner) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
|
this.resource = resource;
|
||||||
this.owner = owner;
|
this.owner = owner;
|
||||||
|
if (owner === "music") {
|
||||||
|
const nextVolume =
|
||||||
|
options.volume !== undefined
|
||||||
|
? this.normalizeVolume(options.volume)
|
||||||
|
: this.musicVolume;
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
this.setResourceVolume(nextVolume);
|
||||||
|
}
|
||||||
this.player.play(resource);
|
this.player.play(resource);
|
||||||
this.connection?.subscribe(this.player);
|
this.connection?.subscribe(this.player);
|
||||||
}
|
}
|
||||||
@@ -76,6 +97,19 @@ export class DiscordPlayer {
|
|||||||
if (!this.canControl(owner)) return;
|
if (!this.canControl(owner)) return;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.owner = "none";
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMusicVolume(): number {
|
||||||
|
return this.musicVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMusicVolume(volume: number): void {
|
||||||
|
const nextVolume = this.normalizeVolume(volume);
|
||||||
|
this.musicVolume = nextVolume;
|
||||||
|
if (this.owner === "music") {
|
||||||
|
this.setResourceVolume(nextVolume);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||||
@@ -87,6 +121,16 @@ export class DiscordPlayer {
|
|||||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||||
return !owner || this.owner === "none" || this.owner === owner;
|
return !owner || this.owner === "none" || this.owner === owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeVolume(volume: number): number {
|
||||||
|
if (!Number.isFinite(volume)) return this.musicVolume;
|
||||||
|
return Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setResourceVolume(volume: number): void {
|
||||||
|
if (!this.resource?.volume) return;
|
||||||
|
this.resource.volume.setVolume(volume);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const discordPlayer = new DiscordPlayer();
|
export const discordPlayer = new DiscordPlayer();
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export async function startRecording(
|
|||||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||||
if (userMetadata.bot) return;
|
if (userMetadata.bot) return;
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
{ userId, username: userMetadata.username },
|
{ userId, username: userMetadata.username },
|
||||||
"Voice activity detected",
|
"Voice activity detected",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Router } from "express";
|
import type { NextFunction, Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { MediaController } from "../media/mediaController";
|
import type { MediaController } from "../media/mediaController";
|
||||||
@@ -6,21 +6,48 @@ import type { MediaMode } from "../media/mediaTypes";
|
|||||||
|
|
||||||
export type MediaRouteController = Pick<
|
export type MediaRouteController = Pick<
|
||||||
MediaController,
|
MediaController,
|
||||||
"getState" | "queue" | "skip" | "stop"
|
"getState" | "queue" | "skip" | "stop" | "setMusicVolume"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function createMediaRoutes(controller: MediaRouteController): Router {
|
export interface MediaRouteOptions {
|
||||||
const router = express.Router();
|
adminPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/media/status", (_req, res, next) => {
|
export function createMediaRoutes(
|
||||||
|
controller: MediaRouteController,
|
||||||
|
options: MediaRouteOptions = {},
|
||||||
|
): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
const { adminPassword } = options;
|
||||||
|
|
||||||
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!adminPassword) return next();
|
||||||
|
const authHeader = req.headers["x-admin-password"];
|
||||||
|
if (authHeader === adminPassword) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply admin auth as router-level middleware so route stack ordering
|
||||||
|
// remains predictable for tests that inspect route handlers.
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/media/status",
|
||||||
|
(_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(controller.getState());
|
res.json(controller.getState());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/queue", async (req, res, next) => {
|
router.post(
|
||||||
|
"/media/queue",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { source, mode = "music" } = req.body as {
|
const { source, mode = "music" } = req.body as {
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -40,23 +67,52 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/skip", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/media/skip",
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(await controller.skip());
|
res.json(await controller.skip());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/stop", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/media/stop",
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
res.json(await controller.stop());
|
res.json(await controller.stop());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/media/volume",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { volume } = req.body as { volume?: number };
|
||||||
|
if (typeof volume !== "number" || Number.isNaN(volume)) {
|
||||||
|
throw new AppError("Volume is required", "INVALID_VOLUME", 400);
|
||||||
|
}
|
||||||
|
if (volume < 0 || volume > 1) {
|
||||||
|
throw new AppError(
|
||||||
|
"Volume must be between 0 and 1",
|
||||||
|
"INVALID_VOLUME",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(await controller.setMusicVolume(volume));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface SharedUIState {
|
|||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
selectedTextGuild: string;
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "messages" | "media" | "review";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Router } from "express";
|
import type { NextFunction, Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
|
|||||||
voiceController: VoiceController;
|
voiceController: VoiceController;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
||||||
broadcaster: ModerationBroadcaster;
|
broadcaster: ModerationBroadcaster;
|
||||||
|
adminPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVoiceRoutes(
|
export function createVoiceRoutes(
|
||||||
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
|
|||||||
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
||||||
| undefined;
|
| undefined;
|
||||||
let broadcaster: ModerationBroadcaster | undefined;
|
let broadcaster: ModerationBroadcaster | undefined;
|
||||||
|
let adminPassword: string | undefined;
|
||||||
|
|
||||||
if ("connect" in options && "disconnect" in options) {
|
if ("connect" in options && "disconnect" in options) {
|
||||||
// Old signature: just VoiceController
|
// Old signature: just VoiceController
|
||||||
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
|
|||||||
voiceController = opts.voiceController;
|
voiceController = opts.voiceController;
|
||||||
patchSharedUIState = opts.patchSharedUIState;
|
patchSharedUIState = opts.patchSharedUIState;
|
||||||
broadcaster = opts.broadcaster;
|
broadcaster = opts.broadcaster;
|
||||||
|
adminPassword = opts.adminPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!adminPassword) return next();
|
||||||
|
const authHeader = req.headers["x-admin-password"];
|
||||||
|
if (authHeader === adminPassword) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/status - Get voice connection status
|
// GET /api/status - Get voice connection status
|
||||||
router.get("/status", (_req, res, next) => {
|
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const status = voiceController.getStatus();
|
const status = voiceController.getStatus();
|
||||||
res.json(status);
|
res.json(status);
|
||||||
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds - List available guilds
|
// GET /api/guilds - List available guilds
|
||||||
router.get("/guilds", (_req, res, next) => {
|
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const guilds = voiceController.listGuilds();
|
const guilds = voiceController.listGuilds();
|
||||||
res.json(guilds);
|
res.json(guilds);
|
||||||
@@ -58,7 +71,9 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
||||||
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => {
|
router.get(
|
||||||
|
"/guilds/:guildId/voice-channels",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
|
|
||||||
@@ -66,15 +81,20 @@ export function createVoiceRoutes(
|
|||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await voiceController.listVoiceChannels(guildId);
|
const channels = await voiceController.listVoiceChannels(
|
||||||
|
guildId as string,
|
||||||
|
);
|
||||||
res.json(channels);
|
res.json(channels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
||||||
router.get("/guilds/:guildId/channels", async (req, res, next) => {
|
router.get(
|
||||||
|
"/guilds/:guildId/channels",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
|
|
||||||
@@ -82,31 +102,21 @@ export function createVoiceRoutes(
|
|||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = await voiceController.listWatchableChannels(guildId);
|
const channels = await voiceController.listWatchableChannels(
|
||||||
|
guildId as string,
|
||||||
|
);
|
||||||
res.json(channels);
|
res.json(channels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
// GET /api/guilds/:guildId/threads - List threads in a guild
|
|
||||||
router.get("/guilds/:guildId/threads", async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { guildId } = req.params;
|
|
||||||
|
|
||||||
if (!guildId) {
|
|
||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threads = await voiceController.listThreads(guildId);
|
|
||||||
res.json(threads);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/connect - Connect to a voice channel
|
// POST /api/connect - Connect to a voice channel
|
||||||
router.post("/connect", async (req, res, next) => {
|
router.post(
|
||||||
|
"/connect",
|
||||||
|
adminAuth,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { guildId, channelId } = req.body as {
|
const { guildId, channelId } = req.body as {
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
@@ -138,10 +148,14 @@ export function createVoiceRoutes(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/disconnect - Disconnect from voice channel
|
// POST /api/disconnect - Disconnect from voice channel
|
||||||
router.post("/disconnect", async (_req, res, next) => {
|
router.post(
|
||||||
|
"/disconnect",
|
||||||
|
adminAuth,
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
logger.info("Disconnecting from voice channel");
|
logger.info("Disconnecting from voice channel");
|
||||||
|
|
||||||
@@ -160,7 +174,8 @@ export function createVoiceRoutes(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
174
src/streaming/index.ts
Normal file
174
src/streaming/index.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
|
import {
|
||||||
|
Streamer as DankStreamer,
|
||||||
|
prepareStream as dankPrepareStream,
|
||||||
|
playStream as dankPlayStream,
|
||||||
|
Utils,
|
||||||
|
Encoders,
|
||||||
|
} from "@dank074/discord-video-stream";
|
||||||
|
|
||||||
|
type VoiceConnectionLike = any;
|
||||||
|
type StreamConnectionLike = any;
|
||||||
|
|
||||||
|
export interface StreamPlayOptions {
|
||||||
|
fps?: number;
|
||||||
|
bitrate?: number | string;
|
||||||
|
includeAudio?: boolean;
|
||||||
|
presetH26x?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamSession {
|
||||||
|
connection: VoiceConnectionLike;
|
||||||
|
stream: StreamConnectionLike;
|
||||||
|
play(source: string | Readable, options?: StreamPlayOptions): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UtilsAPI = {
|
||||||
|
normalizeVideoCodec: (c: string) => c.toUpperCase?.() ?? c,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Streamer {
|
||||||
|
client: Client;
|
||||||
|
dankStreamer: DankStreamer;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
this.dankStreamer = new DankStreamer(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(guildId: string, channelId: string): Promise<StreamSession> {
|
||||||
|
await this.dankStreamer.joinVoice(guildId, channelId);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let currentCommand: any = null;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
try {
|
||||||
|
if (currentCommand?.kill) currentCommand.kill("SIGKILL");
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.dankStreamer.stopStream();
|
||||||
|
this.dankStreamer.leaveVoice();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: {} as any,
|
||||||
|
stream: {} as any,
|
||||||
|
play: async (source: string | Readable, options: StreamPlayOptions = {}) => {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
let targetSource: string | Readable = source;
|
||||||
|
if (typeof source === "string" && source.includes("\n")) {
|
||||||
|
const urls = source.split("\n").filter((u) => u.trim());
|
||||||
|
targetSource = urls[0] ?? source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = options.fps ?? 60;
|
||||||
|
const bitrateStr = String(options.bitrate ?? 8000).replace(/k$/i, "");
|
||||||
|
const bitrateVideo = parseInt(bitrateStr, 10) || 8000;
|
||||||
|
|
||||||
|
console.log("[Streamer] Starting screen share for source:", typeof targetSource === "string" ? targetSource.slice(0, 50) + "..." : "ReadableStream");
|
||||||
|
const { command, output } = dankPrepareStream(targetSource, {
|
||||||
|
encoder: Encoders.software({
|
||||||
|
x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||||
|
x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||||
|
}),
|
||||||
|
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
bitrateVideo: bitrateVideo,
|
||||||
|
frameRate: fps,
|
||||||
|
includeAudio: options.includeAudio !== false,
|
||||||
|
minimizeLatency: false,
|
||||||
|
customInputOptions: ["-fflags nobuffer"],
|
||||||
|
customHeaders: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentCommand = command;
|
||||||
|
|
||||||
|
const webOutput = new PassThrough();
|
||||||
|
const discordOutput = new PassThrough();
|
||||||
|
|
||||||
|
output.pipe(webOutput);
|
||||||
|
output.pipe(discordOutput);
|
||||||
|
|
||||||
|
const globalAny: any = globalThis;
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
globalAny.broadcastVideoToWeb?.(chunk);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
webOutput.on("data", onData);
|
||||||
|
|
||||||
|
command.on("error", (err: Error) => {
|
||||||
|
console.error("[Streamer] Transcoder error:", err);
|
||||||
|
});
|
||||||
|
command.on("stderr", (stderrLine: string) => {
|
||||||
|
console.error("[Streamer] FFMPEG:", stderrLine);
|
||||||
|
});
|
||||||
|
command.on("end", () => {
|
||||||
|
console.log("[Streamer] FFMPEG process ended naturally.");
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[Streamer] Calling dankPlayStream...");
|
||||||
|
await dankPlayStream(discordOutput, this.dankStreamer, undefined);
|
||||||
|
console.log("[Streamer] dankPlayStream completed successfully.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Streamer] dankPlayStream error:", err);
|
||||||
|
} finally {
|
||||||
|
console.log("[Streamer] Cleaning up stream resources.");
|
||||||
|
webOutput.off("data", onData);
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareStream(source: string, _options: any): any {
|
||||||
|
return { command: null, output: new PassThrough() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playStream(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStreamSession(
|
||||||
|
client: Client,
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<StreamSession> {
|
||||||
|
return new Streamer(client).createSession(guildId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playPreparedStream(
|
||||||
|
source: string | Readable,
|
||||||
|
session: StreamSession,
|
||||||
|
options: StreamPlayOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await session.play(source, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playTranscodedPreparedStream(
|
||||||
|
source: string | Readable,
|
||||||
|
session: StreamSession,
|
||||||
|
options: StreamPlayOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await session.play(source, options);
|
||||||
|
}
|
||||||
158
src/streaming/transcoder.ts
Normal file
158
src/streaming/transcoder.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { spawn, ChildProcess } from "node:child_process";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import { retryWithBackoff } from "../retry";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
|
import { transcoderRestartsCounter, transcoderRunningGauge } from "../metrics";
|
||||||
|
|
||||||
|
const logger = createChildLogger("transcoder");
|
||||||
|
|
||||||
|
export interface TranscoderOptions {
|
||||||
|
fps?: number;
|
||||||
|
bitrate?: string | number;
|
||||||
|
preset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Transcoder {
|
||||||
|
proc: ChildProcess | null = null;
|
||||||
|
output: Readable | null = null;
|
||||||
|
stopping = false;
|
||||||
|
restartAttempts = 0;
|
||||||
|
restartTimer: NodeJS.Timeout | null = null;
|
||||||
|
maxRestarts = 6;
|
||||||
|
|
||||||
|
constructor(private source: string, private opts: TranscoderOptions = {}) {}
|
||||||
|
|
||||||
|
start(): { command: ChildProcess; output: Readable } {
|
||||||
|
const fps = this.opts.fps ?? 30;
|
||||||
|
const bitrate = String(this.opts.bitrate ?? "2500k");
|
||||||
|
const preset = this.opts.preset ?? "superfast";
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-i",
|
||||||
|
this.source,
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
preset,
|
||||||
|
"-r",
|
||||||
|
String(fps),
|
||||||
|
"-s",
|
||||||
|
"1280x720",
|
||||||
|
"-b:v",
|
||||||
|
String(bitrate),
|
||||||
|
"-maxrate",
|
||||||
|
"4000k",
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
"-f",
|
||||||
|
"matroska",
|
||||||
|
"-",
|
||||||
|
];
|
||||||
|
|
||||||
|
const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const out = cmd.stdout ?? new PassThrough();
|
||||||
|
|
||||||
|
this.proc = cmd;
|
||||||
|
this.output = out;
|
||||||
|
|
||||||
|
cmd.on("error", (err) => {
|
||||||
|
logger.error({ err }, "transcoder process error");
|
||||||
|
});
|
||||||
|
cmd.on("exit", (code, signal) => {
|
||||||
|
logger.info({ code, signal }, "transcoder exited");
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
// If we didn't explicitly stop, attempt restart with backoff
|
||||||
|
if (!this.stopping) {
|
||||||
|
this.scheduleRestart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transcoderRunningGauge.set(1);
|
||||||
|
|
||||||
|
return { command: cmd, output: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopping = true;
|
||||||
|
if (this.restartTimer) {
|
||||||
|
clearTimeout(this.restartTimer);
|
||||||
|
this.restartTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.proc && !this.proc.killed) this.proc.kill("SIGTERM");
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ e }, "failed to terminate transcoder gracefully");
|
||||||
|
try {
|
||||||
|
if (this.proc && !this.proc.killed) this.proc.kill("SIGKILL");
|
||||||
|
} catch (e2) {
|
||||||
|
logger.warn({ e2 }, "failed to kill transcoder forcefully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
this.output = null;
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRestart() {
|
||||||
|
if (this.restartAttempts >= this.maxRestarts) {
|
||||||
|
logger.error({ attempts: this.restartAttempts }, "transcoder reached max restart attempts");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(30000, 1000 * Math.pow(2, this.restartAttempts));
|
||||||
|
this.restartAttempts += 1;
|
||||||
|
transcoderRestartsCounter.inc();
|
||||||
|
logger.info({ delay, attempt: this.restartAttempts }, "scheduling transcoder restart");
|
||||||
|
this.restartTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.start();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "transcoder restart failed");
|
||||||
|
this.scheduleRestart();
|
||||||
|
}
|
||||||
|
}, delay) as unknown as NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startWithRetry(retries = 2) {
|
||||||
|
return retryWithBackoff(() => Promise.resolve(this.start()), {
|
||||||
|
retries,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
this.stopping = true;
|
||||||
|
if (this.restartTimer) {
|
||||||
|
clearTimeout(this.restartTimer);
|
||||||
|
this.restartTimer = null;
|
||||||
|
}
|
||||||
|
if (this.proc && !this.proc.killed) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.proc?.once("exit", () => resolve());
|
||||||
|
try {
|
||||||
|
this.proc?.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
this.proc?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => resolve(), 5000);
|
||||||
|
}).then(() => {
|
||||||
|
this.proc = null;
|
||||||
|
this.output = null;
|
||||||
|
transcoderRunningGauge.set(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareTranscoder(source: string, options: TranscoderOptions = {}) {
|
||||||
|
const t = new Transcoder(source, options);
|
||||||
|
const { command, output } = t.start();
|
||||||
|
return { transcoder: t, command, output };
|
||||||
|
}
|
||||||
@@ -83,46 +83,6 @@ export class VoiceController {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listThreads(guildId: string): Promise<ChannelSummary[]> {
|
|
||||||
const guild = this.getGuild(guildId);
|
|
||||||
await guild.channels.fetch().catch(() => null);
|
|
||||||
|
|
||||||
const threads: ChannelSummary[] = [];
|
|
||||||
type ThreadFetchResult = {
|
|
||||||
threads: Map<string, { id: string; name: string; type: string }>;
|
|
||||||
};
|
|
||||||
for (const channel of guild.channels.cache.values()) {
|
|
||||||
const threadParent = channel as typeof channel & {
|
|
||||||
threads?: {
|
|
||||||
fetch: (options: {
|
|
||||||
archived: boolean;
|
|
||||||
limit: number;
|
|
||||||
}) => Promise<ThreadFetchResult>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
if (!threadParent.threads?.fetch) continue;
|
|
||||||
|
|
||||||
for (const archived of [false, true]) {
|
|
||||||
const fetched = await threadParent.threads
|
|
||||||
.fetch({ archived, limit: 100 })
|
|
||||||
.catch(() => null);
|
|
||||||
if (!fetched?.threads) continue;
|
|
||||||
|
|
||||||
for (const thread of fetched.threads.values()) {
|
|
||||||
threads.push({
|
|
||||||
id: thread.id,
|
|
||||||
name: `${channel.name} / ${thread.name}`,
|
|
||||||
type: thread.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(
|
|
||||||
new Map(threads.map((thread) => [thread.id, thread])).values(),
|
|
||||||
).sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||||
if (!this.client.isReady()) {
|
if (!this.client.isReady()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
|
|||||||
137
src/webserver.ts
137
src/webserver.ts
@@ -1,13 +1,19 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Streamer } from "@dank074/discord-video-stream";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Streamer } from "./streaming";
|
||||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import express from "express";
|
import express, {
|
||||||
|
type NextFunction,
|
||||||
|
type Request,
|
||||||
|
type Response,
|
||||||
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import * as prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
import { config } from "./config";
|
||||||
import { AppError } from "./errors";
|
import { AppError } from "./errors";
|
||||||
import { createChildLogger, logger } from "./logger";
|
import { createChildLogger, logger } from "./logger";
|
||||||
import { MediaController } from "./media/mediaController";
|
import { MediaController } from "./media/mediaController";
|
||||||
@@ -25,6 +31,9 @@ import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
|||||||
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const wsLogger = createChildLogger("webserver");
|
const wsLogger = createChildLogger("webserver");
|
||||||
|
|
||||||
const activeUsers = new Map<
|
const activeUsers = new Map<
|
||||||
@@ -35,6 +44,7 @@ const activeUsers = new Map<
|
|||||||
type VoiceGlobals = typeof globalThis & {
|
type VoiceGlobals = typeof globalThis & {
|
||||||
moderationBroadcaster?: ModerationBroadcaster;
|
moderationBroadcaster?: ModerationBroadcaster;
|
||||||
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||||
|
broadcastVideoToWeb?: (chunk: Buffer) => void;
|
||||||
updateActiveUser?: (
|
updateActiveUser?: (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
@@ -46,11 +56,15 @@ interface SharedUIState {
|
|||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
selectedTextGuild: string;
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "messages" | "media" | "review";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaSettings {
|
||||||
|
musicVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
type SharedUIStatePatch = Partial<SharedUIState> & {
|
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
selectedGuild?: string;
|
selectedGuild?: string;
|
||||||
};
|
};
|
||||||
@@ -65,18 +79,26 @@ const defaultSharedUIState: SharedUIState = {
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultMediaSettings: MediaSettings = {
|
||||||
|
musicVolume: 1,
|
||||||
|
};
|
||||||
|
|
||||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
||||||
|
|
||||||
export function normalizeSharedUIState(
|
export function normalizeSharedUIState(
|
||||||
value: SharedUIStatePatch,
|
value: SharedUIStatePatch,
|
||||||
): SharedUIState {
|
): SharedUIState {
|
||||||
const legacyGuild = value.selectedGuild ?? "";
|
const guild = value.selectedGuild ?? "";
|
||||||
return {
|
return {
|
||||||
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
selectedVoiceGuild: value.selectedVoiceGuild ?? guild,
|
||||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||||
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
selectedTextGuild: value.selectedTextGuild ?? guild,
|
||||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||||
activeTab: value.activeTab === "text" ? "text" : "voice",
|
activeTab: (["voice", "messages", "media", "review"].includes(
|
||||||
|
value.activeTab ?? "",
|
||||||
|
)
|
||||||
|
? value.activeTab
|
||||||
|
: "voice") as "voice" | "messages" | "media" | "review",
|
||||||
isListening: value.isListening ?? false,
|
isListening: value.isListening ?? false,
|
||||||
isStreaming: value.isStreaming ?? false,
|
isStreaming: value.isStreaming ?? false,
|
||||||
};
|
};
|
||||||
@@ -88,6 +110,17 @@ async function initializeSharedUIState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initializeMediaSettings(): Promise<MediaSettings> {
|
||||||
|
const stored = await getPersistedValue(
|
||||||
|
"media-settings",
|
||||||
|
defaultMediaSettings,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...defaultMediaSettings,
|
||||||
|
...(stored as MediaSettings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getSharedUIState(): SharedUIState {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
@@ -109,8 +142,14 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
|||||||
if (typeof patch.selectedTextChannel === "string") {
|
if (typeof patch.selectedTextChannel === "string") {
|
||||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||||
}
|
}
|
||||||
if (patch.activeTab === "voice" || patch.activeTab === "text") {
|
if (
|
||||||
sharedUIState.activeTab = patch.activeTab;
|
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
|
||||||
|
) {
|
||||||
|
sharedUIState.activeTab = patch.activeTab as
|
||||||
|
| "voice"
|
||||||
|
| "messages"
|
||||||
|
| "media"
|
||||||
|
| "review";
|
||||||
}
|
}
|
||||||
if (typeof patch.isListening === "boolean") {
|
if (typeof patch.isListening === "boolean") {
|
||||||
sharedUIState.isListening = patch.isListening;
|
sharedUIState.isListening = patch.isListening;
|
||||||
@@ -124,13 +163,15 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
|||||||
|
|
||||||
// Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS)
|
// Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS)
|
||||||
function upsample(mono24k: Buffer): Buffer {
|
function upsample(mono24k: Buffer): Buffer {
|
||||||
const out = Buffer.alloc(mono24k.length * 4);
|
const numSamples = mono24k.length / 2;
|
||||||
for (let i = 0; i < mono24k.length / 2; i++) {
|
const out = Buffer.alloc(numSamples * 8);
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
const s = mono24k.readInt16LE(i * 2);
|
const s = mono24k.readInt16LE(i * 2);
|
||||||
out.writeInt16LE(s, i * 8);
|
const base = i * 8;
|
||||||
out.writeInt16LE(s, i * 8 + 2);
|
out.writeInt16LE(s, base);
|
||||||
out.writeInt16LE(s, i * 8 + 4);
|
out.writeInt16LE(s, base + 2);
|
||||||
out.writeInt16LE(s, i * 8 + 6);
|
out.writeInt16LE(s, base + 4);
|
||||||
|
out.writeInt16LE(s, base + 6);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -144,7 +185,7 @@ function rmsDb(pcm: Buffer): number {
|
|||||||
sum += s * s;
|
sum += s * s;
|
||||||
}
|
}
|
||||||
const rms = Math.sqrt(sum / samples);
|
const rms = Math.sqrt(sum / samples);
|
||||||
return 20 * Math.log10(Math.max(rms, 1e-10));
|
return 20 * Math.log10(rms);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startWebserver(
|
export async function startWebserver(
|
||||||
@@ -153,6 +194,7 @@ export async function startWebserver(
|
|||||||
voiceController: VoiceController,
|
voiceController: VoiceController,
|
||||||
) {
|
) {
|
||||||
await initializeSharedUIState();
|
await initializeSharedUIState();
|
||||||
|
let mediaSettings = await initializeMediaSettings();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -164,11 +206,23 @@ export async function startWebserver(
|
|||||||
// Create broadcaster instance
|
// Create broadcaster instance
|
||||||
const broadcaster = createBroadcaster();
|
const broadcaster = createBroadcaster();
|
||||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||||
|
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
|
||||||
|
|
||||||
const streamer = new Streamer(_client);
|
const streamer = new Streamer(_client);
|
||||||
const screenController = createScreenShareController({
|
const screenController = createScreenShareController({
|
||||||
getVoiceStatus: () => voiceController.getStatus(),
|
getVoiceStatus: () => voiceController.getStatus(),
|
||||||
streamer,
|
streamer,
|
||||||
|
useTranscoder: true,
|
||||||
|
onBeforeStreamStart: async (guildId: string, channelId: string) => {
|
||||||
|
await voiceController.disconnect();
|
||||||
|
// Wait for Discord gateway to fully process the disconnect
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
},
|
||||||
|
onAfterStreamEnd: async (guildId: string, channelId: string) => {
|
||||||
|
const current = voiceController.getStatus();
|
||||||
|
if (current.connected && current.activeGuildId === guildId) return;
|
||||||
|
await voiceController.connect(guildId, channelId);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaController = new MediaController({
|
const mediaController = new MediaController({
|
||||||
@@ -176,6 +230,11 @@ export async function startWebserver(
|
|||||||
isBrowserStreaming: () => sharedUIState.isStreaming,
|
isBrowserStreaming: () => sharedUIState.isStreaming,
|
||||||
screenController,
|
screenController,
|
||||||
onStateChange: (state) => broadcaster.mediaState(state),
|
onStateChange: (state) => broadcaster.mediaState(state),
|
||||||
|
initialMusicVolume: mediaSettings.musicVolume,
|
||||||
|
onMusicVolumeChange: async (volume) => {
|
||||||
|
mediaSettings = { ...mediaSettings, musicVolume: volume };
|
||||||
|
await setPersistedValue("media-settings", mediaSettings);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
|
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
|
||||||
@@ -185,7 +244,7 @@ export async function startWebserver(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.path.startsWith("/api/")) {
|
if (req.path.startsWith("/api/")) {
|
||||||
res.set("Cache-Control", "no-store");
|
res.set("Cache-Control", "no-store");
|
||||||
}
|
}
|
||||||
@@ -208,18 +267,21 @@ export async function startWebserver(
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
app.use(express.static(path.join(__dirname, "../public/app")));
|
||||||
|
|
||||||
app.get("/", (_req, res) => {
|
app.get("/", (_req: Request, res: Response) => {
|
||||||
const reactIndex = path.join(__dirname, "../public/app/index.html");
|
const reactIndex = path.join(__dirname, "../public/app/index.html");
|
||||||
if (fs.existsSync(reactIndex)) {
|
if (fs.existsSync(reactIndex)) {
|
||||||
res.sendFile(reactIndex);
|
res.sendFile(reactIndex);
|
||||||
} else {
|
return;
|
||||||
res.sendFile(path.join(__dirname, "../public/index.html"));
|
|
||||||
}
|
}
|
||||||
|
res
|
||||||
|
.status(503)
|
||||||
|
.send("React dashboard is not built. Run pnpm run build:web.");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req: Request, res: Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -230,12 +292,22 @@ export async function startWebserver(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Metrics endpoint
|
// Metrics endpoint
|
||||||
app.get("/metrics", async (_req, res) => {
|
app.get("/metrics", async (_req: Request, res: Response) => {
|
||||||
res.set("Content-Type", "text/plain");
|
res.set("Content-Type", "text/plain");
|
||||||
uptimeGauge.set(process.uptime());
|
uptimeGauge.set(process.uptime());
|
||||||
res.send(await getMetrics());
|
res.send(await getMetrics());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Simple password-based auth
|
||||||
|
app.post("/api/auth/login", (req: Request, res: Response) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (password === config.ADMIN_PASSWORD) {
|
||||||
|
res.json({ ok: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Invalid password" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Register route modules
|
// Register route modules
|
||||||
app.use(
|
app.use(
|
||||||
"/api",
|
"/api",
|
||||||
@@ -247,12 +319,18 @@ export async function startWebserver(
|
|||||||
voiceController,
|
voiceController,
|
||||||
patchSharedUIState,
|
patchSharedUIState,
|
||||||
broadcaster,
|
broadcaster,
|
||||||
|
adminPassword: config.ADMIN_PASSWORD,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("/api", createMessageRoutes());
|
app.use("/api", createMessageRoutes());
|
||||||
app.use("/api", createAnalysisRoutes());
|
app.use("/api", createAnalysisRoutes());
|
||||||
app.use("/api", createSyncRoutes(_client));
|
app.use("/api", createSyncRoutes(_client));
|
||||||
app.use("/api", createMediaRoutes(mediaController));
|
app.use(
|
||||||
|
"/api",
|
||||||
|
createMediaRoutes(mediaController, {
|
||||||
|
adminPassword: config.ADMIN_PASSWORD,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Inbound: Discord PCM → tagged chunks → browser
|
// Inbound: Discord PCM → tagged chunks → browser
|
||||||
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
||||||
@@ -272,6 +350,19 @@ export async function startWebserver(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Outbound: server video stream (matroska chunks) -> browser clients
|
||||||
|
(globalThis as VoiceGlobals).broadcastVideoToWeb = (chunk: Buffer) => {
|
||||||
|
for (const client of broadcaster.getClients()) {
|
||||||
|
if (client.readyState === 1) {
|
||||||
|
try {
|
||||||
|
client.send(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
wsLogger.warn({ err }, "Failed to send video chunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
(globalThis as VoiceGlobals).updateActiveUser = (
|
(globalThis as VoiceGlobals).updateActiveUser = (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
|
|||||||
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./frontend/index.html", "./frontend/src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
||||||
|
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
||||||
|
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
||||||
|
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
||||||
|
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
||||||
|
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
33
test_dank.ts
Normal file
33
test_dank.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { prepareStream, Encoders } from "@dank074/discord-video-stream";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("Starting prepareStream...");
|
||||||
|
const { command, output } = prepareStream("https://rr3---sn-2uuxa3vh-unte.googlevideo.com/videoplayback?expire=1779046518&ei=FsQJatGDGNqp9fwP4qz4SA&ip=180.252.24.35&id=o-APFvGry6yPgoap-1RT0pu59DxD-pcXC4oXtMQuCMtjOy&itag=18&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=618&met=1779024918%2C&mh=VD&mm=31%2C29&mn=sn-2uuxa3vh-unte%2Csn-oguelnze&ms=au%2Crdu&mv=m&mvi=3&pcm2cms=yes&pl=20&rms=au%2Cau&initcwndbps=763750&bui=AbKmrwofOLw_tOID4kBHnWgaXP2wnDlEYmbyHyrnZk1n7vjMaQIuY046T9MhH0PuL9JGJwj6YlwCr2Uu&spc=96Xrv8WI7iTS7MOF7Dvg-8a3RT-sMI9ux49zUa4Pg6GHkzXExSS0&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&cnr=14&ratebypass=yes&dur=19.063&lmt=1772437158054287&mt=1779024581&fvip=4&fexp=51565116%2C51565681&c=ANDROID_VR&txp=4530534&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AHEqNM4wRgIhAJe1vu37ssUQQm3scVgXY7NYDx_frKW1AZ4gHRdcqsUlAiEAkKt6jxaCNvaEh6jag1OWheo5qQeu3ObfCCoQIZ9xnCA%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpcm2cms%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRQIhAMkeJ6WrDFU7fTfSb6s_WbdDpn4J-4NqkfzKV3B_y1cgAiBJ7aExkhh-0hvIWwNorjDwoOkTIKIfmzx6o6Z3mxlazA%3D%3D", {
|
||||||
|
encoder: Encoders.software(),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
includeAudio: true,
|
||||||
|
minimizeLatency: false // Add this
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream("/mnt/code/bete/test_out.nut");
|
||||||
|
output.pipe(fileStream);
|
||||||
|
|
||||||
|
command.on("error", (err, stdout, stderr) => {
|
||||||
|
console.error("FFMPEG ERROR:", err.message);
|
||||||
|
});
|
||||||
|
command.on("stderr", (stderrLine) => {
|
||||||
|
console.log("FFMPEG LOG:", stderrLine);
|
||||||
|
});
|
||||||
|
command.on("end", () => {
|
||||||
|
console.log("FFMPEG FINISHED");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try { command.kill("SIGKILL"); } catch(e) {}
|
||||||
|
process.exit(0);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
run();
|
||||||
27
test_dank2.ts
Normal file
27
test_dank2.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { prepareStream, Encoders } from "@dank074/discord-video-stream";
|
||||||
|
import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("Starting prepareStream...");
|
||||||
|
const { command, output } = prepareStream("https://samplelib.com/preview/mp4/sample-5s.mp4", {
|
||||||
|
encoder: Encoders.software(),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
includeAudio: true,
|
||||||
|
minimizeLatency: false // Add this
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { video, audio } = await demux(output, { format: "nut" });
|
||||||
|
console.log("DEMUX VIDEO:", !!video);
|
||||||
|
console.log("DEMUX AUDIO:", !!audio);
|
||||||
|
} catch(e) {
|
||||||
|
console.error("DEMUX ERR:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try { command.kill("SIGKILL"); } catch(e) {}
|
||||||
|
process.exit(0);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
run();
|
||||||
BIN
test_out.nut
Normal file
BIN
test_out.nut
Normal file
Binary file not shown.
18
test_stream.ts
Normal file
18
test_stream.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { prepareStream } from "@dank074/discord-video-stream";
|
||||||
|
import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js";
|
||||||
|
import { Encoders } from "@dank074/discord-video-stream/dist/media/encoders/index.js";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { command, output } = prepareStream("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", {
|
||||||
|
encoder: Encoders.software(),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
includeAudio: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { video, audio } = await demux(output, { format: "nut" });
|
||||||
|
console.log("Video found:", !!video);
|
||||||
|
console.log("Audio found:", !!audio);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -194,12 +194,13 @@ describe("MediaController", () => {
|
|||||||
expect(state).toEqual({
|
expect(state).toEqual({
|
||||||
playing: false,
|
playing: false,
|
||||||
activeMode: null,
|
activeMode: null,
|
||||||
|
musicVolume: 1,
|
||||||
current: null,
|
current: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts screen share mode without resolving music source", async () => {
|
it("starts screen share mode by resolving the video source", async () => {
|
||||||
const screenPlayback = deferred();
|
const screenPlayback = deferred();
|
||||||
const screenController: ScreenShareController = {
|
const screenController: ScreenShareController = {
|
||||||
isActive: vi.fn(() => false),
|
isActive: vi.fn(() => false),
|
||||||
@@ -208,7 +209,7 @@ describe("MediaController", () => {
|
|||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const resolveMediaSource = vi.fn(async (input) => source(input));
|
const resolveMediaSource = vi.fn(async (input, mode) => source(input));
|
||||||
const controller = new MediaController({
|
const controller = new MediaController({
|
||||||
isVoiceConnected: () => true,
|
isVoiceConnected: () => true,
|
||||||
isBrowserStreaming: () => false,
|
isBrowserStreaming: () => false,
|
||||||
@@ -224,7 +225,7 @@ describe("MediaController", () => {
|
|||||||
expect(screenController.start).toHaveBeenCalledWith(
|
expect(screenController.start).toHaveBeenCalledWith(
|
||||||
"https://youtu.be/video",
|
"https://youtu.be/video",
|
||||||
);
|
);
|
||||||
expect(resolveMediaSource).not.toHaveBeenCalled();
|
expect(resolveMediaSource).toHaveBeenCalledWith("https://youtu.be/video", "screen");
|
||||||
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
|
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type Spawn = typeof nodeSpawn;
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
DiscordPlayerOwner,
|
DiscordPlayerOwner,
|
||||||
@@ -23,13 +24,15 @@ class FakeProcess extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createMusicPlayer", () => {
|
describe("createMusicPlayer", () => {
|
||||||
it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => {
|
it("spawns ffmpeg as raw PCM and passes stdout to Discord", async () => {
|
||||||
const proc = new FakeProcess();
|
const proc = new FakeProcess();
|
||||||
const spawn = vi.fn(() => proc);
|
const spawn = vi.fn(() => proc);
|
||||||
const discordPlayer: DiscordAudioPlayer = {
|
const discordPlayer: DiscordAudioPlayer = {
|
||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -57,18 +60,21 @@ describe("createMusicPlayer", () => {
|
|||||||
"https://example.com/song.mp3",
|
"https://example.com/song.mp3",
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
],
|
],
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music");
|
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects playback when Discord is not connected", () => {
|
it("rejects playback when Discord is not connected", () => {
|
||||||
@@ -77,6 +83,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => false,
|
isConnected: () => false,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -102,6 +110,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -128,6 +138,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { PassThrough } from "node:stream";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { AppError } from "../../src/errors";
|
import { AppError } from "../../src/errors";
|
||||||
import type { DiscordPlayerOwner } from "../../src/media/mediaTypes";
|
import type { DiscordPlayerOwner } from "../../src/media/mediaTypes";
|
||||||
import { createScreenShareController } from "../../src/media/screenShareController";
|
import { createScreenShareController } from "../../src/media/screenShareController";
|
||||||
|
|
||||||
function createDependencies() {
|
function createDependencies() {
|
||||||
const output = new PassThrough();
|
const session = {
|
||||||
|
play: vi.fn(() => new Promise<void>(() => {})),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
getVoiceStatus: vi.fn(() => ({
|
getVoiceStatus: vi.fn(() => ({
|
||||||
connected: true,
|
connected: true,
|
||||||
@@ -14,12 +16,11 @@ function createDependencies() {
|
|||||||
})),
|
})),
|
||||||
getPlayerOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getPlayerOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"),
|
getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"),
|
||||||
prepareStream: vi.fn(() => ({
|
streamer: {
|
||||||
command: { kill: vi.fn() },
|
createSession: vi.fn(async () => session),
|
||||||
output,
|
client: {},
|
||||||
})),
|
},
|
||||||
playStream: vi.fn(() => new Promise<void>(() => {})),
|
session,
|
||||||
streamer: { id: "streamer" },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,14 +34,17 @@ describe("createScreenShareController", () => {
|
|||||||
expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith(
|
expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith(
|
||||||
"https://youtu.be/video",
|
"https://youtu.be/video",
|
||||||
);
|
);
|
||||||
expect(dependencies.prepareStream).toHaveBeenCalledWith(
|
expect(dependencies.streamer.createSession).toHaveBeenCalledWith(
|
||||||
"https://cdn.example.com/video.mp4",
|
"guild-1",
|
||||||
expect.objectContaining({ includeAudio: true }),
|
"channel-1",
|
||||||
);
|
);
|
||||||
expect(dependencies.playStream).toHaveBeenCalledWith(
|
expect(dependencies.session.play).toHaveBeenCalledWith(
|
||||||
dependencies.prepareStream.mock.results[0].value.output,
|
"https://cdn.example.com/video.mp4",
|
||||||
dependencies.streamer,
|
expect.objectContaining({
|
||||||
{ type: "go-live" },
|
includeAudio: true,
|
||||||
|
fps: 30,
|
||||||
|
bitrate: 2500,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(controller.isActive()).toBe(true);
|
expect(controller.isActive()).toBe(true);
|
||||||
playback.stop();
|
playback.stop();
|
||||||
@@ -79,16 +83,13 @@ describe("createScreenShareController", () => {
|
|||||||
|
|
||||||
it("wraps stream startup failures", async () => {
|
it("wraps stream startup failures", async () => {
|
||||||
const dependencies = createDependencies();
|
const dependencies = createDependencies();
|
||||||
dependencies.playStream.mockImplementation(() => {
|
dependencies.session.play.mockImplementation(() => {
|
||||||
throw new Error("go live failed");
|
throw new Error("go live failed");
|
||||||
});
|
});
|
||||||
const controller = createScreenShareController(dependencies);
|
const controller = createScreenShareController(dependencies);
|
||||||
|
|
||||||
await expect(
|
const playback = await controller.start("https://youtu.be/video");
|
||||||
controller.start("https://youtu.be/video"),
|
|
||||||
).rejects.toMatchObject({
|
await expect(playback.done).rejects.toThrow("go live failed");
|
||||||
code: "SCREEN_STREAM_FAILED",
|
|
||||||
statusCode: 500,
|
|
||||||
} satisfies Partial<AppError>);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
75
tests/moderation/llmLive.test.ts
Normal file
75
tests/moderation/llmLive.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { config } from "../../src/config";
|
||||||
|
import { runModerationAnalysis } from "../../src/moderation/llmModerationClient";
|
||||||
|
import type { MessageRecord } from "../../src/moderation/types";
|
||||||
|
|
||||||
|
describe("LLM Live Integration Test", () => {
|
||||||
|
// Hanya jalankan jika API Key tersedia
|
||||||
|
const hasApiKey =
|
||||||
|
!!config.AI_LLM_API_KEY && config.AI_LLM_API_KEY !== "your-api-key";
|
||||||
|
|
||||||
|
it.runIf(hasApiKey)(
|
||||||
|
"should successfully call real LLM API and parse response",
|
||||||
|
async () => {
|
||||||
|
console.log(`Using Model: ${config.AI_LLM_MODEL}`);
|
||||||
|
console.log(`Base URL: ${config.AI_LLM_BASE_URL}`);
|
||||||
|
|
||||||
|
const mockMessages: MessageRecord[] = [
|
||||||
|
{
|
||||||
|
id: "test-msg-1",
|
||||||
|
guild_id: "guild-1",
|
||||||
|
channel_id: "channel-1",
|
||||||
|
thread_id: null,
|
||||||
|
user_id: "user-1",
|
||||||
|
username: "Tester",
|
||||||
|
avatar_url: null,
|
||||||
|
content: "This is a clean test message.",
|
||||||
|
edited_content: null,
|
||||||
|
created_at: Date.now(),
|
||||||
|
edited_at: null,
|
||||||
|
deleted_at: null,
|
||||||
|
type: "text",
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "test-msg-2",
|
||||||
|
guild_id: "guild-1",
|
||||||
|
channel_id: "channel-1",
|
||||||
|
thread_id: null,
|
||||||
|
user_id: "user-2",
|
||||||
|
username: "BadActor",
|
||||||
|
avatar_url: null,
|
||||||
|
content: "I will kill you and steal your data! DIE!",
|
||||||
|
edited_content: null,
|
||||||
|
created_at: Date.now() + 1000,
|
||||||
|
edited_at: null,
|
||||||
|
deleted_at: null,
|
||||||
|
type: "text",
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runModerationAnalysis({
|
||||||
|
targets: mockMessages,
|
||||||
|
contextText: "Testing moderation system stability.",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Raw Response received (first 100 chars):",
|
||||||
|
JSON.stringify(result.raw).substring(0, 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results).toHaveLength(2);
|
||||||
|
|
||||||
|
const cleanMsg = result.results.find((r) => r.messageId === "test-msg-1");
|
||||||
|
const badMsg = result.results.find((r) => r.messageId === "test-msg-2");
|
||||||
|
|
||||||
|
expect(cleanMsg?.status).toBe("clean");
|
||||||
|
expect(["warn", "flagged"]).toContain(badMsg?.status);
|
||||||
|
|
||||||
|
console.log("Clean Message Result:", cleanMsg);
|
||||||
|
console.log("Bad Message Result:", badMsg);
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
); // 30s timeout untuk LLM
|
||||||
|
});
|
||||||
59
tests/streaming/playTranscode.test.ts
Normal file
59
tests/streaming/playTranscode.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual("node:child_process");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
spawn: (cmd: string, args: string[], opts: any) => {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
const listeners: Record<string, Function[]> = {};
|
||||||
|
const proc: any = {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
kill: vi.fn(() => {
|
||||||
|
(listeners.exit || []).forEach((fn) => fn(0, "SIGKILL"));
|
||||||
|
}),
|
||||||
|
on: (ev: string, fn: Function) => {
|
||||||
|
listeners[ev] = listeners[ev] || [];
|
||||||
|
listeners[ev].push(fn);
|
||||||
|
},
|
||||||
|
off: (ev: string, fn: Function) => {
|
||||||
|
listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
stdoutWrite: (d: Buffer | string) => stdout.write(d),
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
(listeners.exit || []).forEach((fn) => fn(null, null));
|
||||||
|
}, 10);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { playTranscodedPreparedStream } from "../../src/streaming/index";
|
||||||
|
|
||||||
|
describe("playTranscodedPreparedStream", () => {
|
||||||
|
it("pipes transcoder output to session and broadcasts to web", async () => {
|
||||||
|
// mock global broadcast
|
||||||
|
const broadcasts: Buffer[] = [];
|
||||||
|
(globalThis as any).broadcastVideoToWeb = (chunk: Buffer) => broadcasts.push(Buffer.from(chunk));
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
connection: { channel: { id: "c" } },
|
||||||
|
stream: { playVideo: () => null, playAudio: () => null },
|
||||||
|
play: vi.fn().mockImplementation(async (readable) => {
|
||||||
|
// consume a bit from readable to simulate playback
|
||||||
|
readable.on("data", (d: Buffer) => {});
|
||||||
|
// resolve after a short delay
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
}),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await playTranscodedPreparedStream("http://example.test/stream", session, { fps: 30 });
|
||||||
|
expect(session.play).toHaveBeenCalled();
|
||||||
|
expect(broadcasts.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
tests/streaming/transcoder.test.ts
Normal file
52
tests/streaming/transcoder.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
|
// Mock spawn to avoid calling real ffmpeg
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual("node:child_process");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
spawn: (cmd: string, args: string[], opts: any) => {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
const listeners: Record<string, Function[]> = {};
|
||||||
|
const proc: any = {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
kill: vi.fn(() => {
|
||||||
|
// emit exit when killed
|
||||||
|
(listeners.exit || []).forEach((fn) => fn(0, "SIGKILL"));
|
||||||
|
}),
|
||||||
|
on: (ev: string, fn: Function) => {
|
||||||
|
listeners[ev] = listeners[ev] || [];
|
||||||
|
listeners[ev].push(fn);
|
||||||
|
},
|
||||||
|
off: (ev: string, fn: Function) => {
|
||||||
|
listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
stdoutWrite: (d: Buffer | string) => stdout.write(d),
|
||||||
|
};
|
||||||
|
// simulate async start
|
||||||
|
setTimeout(() => {
|
||||||
|
(listeners.exit || []).forEach((fn) => fn(null, null));
|
||||||
|
}, 10);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { prepareTranscoder } from "../../src/streaming/transcoder";
|
||||||
|
|
||||||
|
describe("Transcoder", () => {
|
||||||
|
it("starts ffmpeg and returns output stream and command", () => {
|
||||||
|
const { transcoder, command, output } = prepareTranscoder("http://example.test/video", { fps: 24 });
|
||||||
|
expect(transcoder).toBeTruthy();
|
||||||
|
expect(command).toBeTruthy();
|
||||||
|
expect(output).toBeTruthy();
|
||||||
|
expect(typeof command.kill).toBe("function");
|
||||||
|
// write some data and ensure output is readable
|
||||||
|
const wrote = command.stdoutWrite?.("hello");
|
||||||
|
expect(output.readable).toBe(true);
|
||||||
|
transcoder.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const videoStreamPackage = JSON.parse(
|
|
||||||
readFileSync("vendor/Discord-video-stream/package.json", "utf8"),
|
|
||||||
) as {
|
|
||||||
devDependencies?: Record<string, string>;
|
|
||||||
peerDependencies?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Discord video stream workspace dependencies", () => {
|
|
||||||
it("uses the local selfbot workspace package for development", () => {
|
|
||||||
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
|
|
||||||
"workspace:*",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
|
|
||||||
).toBe("^3.6.0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
1
vendor/Discord-video-stream
vendored
1
vendor/Discord-video-stream
vendored
Submodule vendor/Discord-video-stream deleted from fb83645d73
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user