refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -11,20 +11,21 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/bete
|
||||
REGISTRY: ghcr.io
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
service: [frontend, backend, discord-gateway]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Normalize image name
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -35,19 +36,28 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: infra/docker/Dockerfile.${{ matrix.service }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/bete-${{ matrix.service }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/bete-${{ matrix.service }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ENV_FILE: ${{ secrets.ENV_FILE }}
|
||||
@@ -55,7 +65,7 @@ jobs:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
envs: IMAGE_NAME,GHCR_USERNAME,GHCR_TOKEN,ENV_FILE
|
||||
envs: GHCR_USERNAME,GHCR_TOKEN,ENV_FILE
|
||||
script: |
|
||||
set -eu
|
||||
|
||||
@@ -63,27 +73,58 @@ jobs:
|
||||
mkdir -p "$APP_DIR"
|
||||
cd "$APP_DIR"
|
||||
|
||||
printf '%s\nIMAGE_NAME=%s:latest\n' "$ENV_FILE" "$IMAGE_NAME" > .env
|
||||
printf '%s\n' "$ENV_FILE" > .env
|
||||
|
||||
cat > docker-compose.yml <<'EOF'
|
||||
cat > docker-compose.yml <<'COMPOSE_EOF'
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_NAME}
|
||||
container_name: imphenbot-app
|
||||
backend:
|
||||
image: ghcr.io/${OWNER:-mytheclipse}/bete-backend:latest
|
||||
container_name: imphenbot-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./recordings:/app/recordings
|
||||
- ./.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
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
WEBSERVER_PORT: 3000
|
||||
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"
|
||||
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.imphenbot-backend.rule=Host(`imphnen.asepharyana.my.id`) && PathPrefix(`/api`, `/ws`)"
|
||||
- "traefik.http.routers.imphenbot-backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.imphenbot-backend.tls=true"
|
||||
- "traefik.http.services.imphenbot-backend.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- discord-gateway
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
discord-gateway:
|
||||
image: ghcr.io/${OWNER:-mytheclipse}/bete-discord-gateway:latest
|
||||
container_name: imphenbot-discord-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- ./recordings:/app/recordings
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/${OWNER:-mytheclipse}/bete-frontend:latest
|
||||
container_name: imphenbot-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_URL: https://imphnen.asepharyana.my.id
|
||||
VITE_WS_URL: wss://imphnen.asepharyana.my.id
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.imphenbot-frontend.rule=Host(`imphnen.asepharyana.my.id`)"
|
||||
- "traefik.http.routers.imphenbot-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.imphenbot-frontend.tls=true"
|
||||
- "traefik.http.services.imphenbot-frontend.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
@@ -91,9 +132,8 @@ jobs:
|
||||
app-shared-net:
|
||||
name: app-shared-net
|
||||
external: true
|
||||
EOF
|
||||
COMPOSE_EOF
|
||||
|
||||
touch .muxer-queue.db .muxer-queue.db-shm .muxer-queue.db-wal
|
||||
mkdir -p recordings
|
||||
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
FROM nixos/nix:latest
|
||||
|
||||
SHELL ["/bin/sh", "-c"]
|
||||
|
||||
ENV NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
|
||||
# Install all system dependencies in a single nix profile to avoid version conflicts.
|
||||
# The NixOS base image lacks common Unix utilities (sed, coreutils, etc.) that
|
||||
# native Node.js post-install scripts (node-pre-gyp, prebuild-install) require.
|
||||
# We pin nixpkgs to a specific commit for reproducible builds.
|
||||
ARG NIXPKGS_COMMIT=64c08a7ca051951c8eae34e3e3cb1e202fe36786
|
||||
|
||||
RUN nix profile install \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#gnused" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#coreutils-full" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#nodejs_22" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#ffmpeg" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#python3" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#gnumake" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#gcc" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#pkg-config" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#vips" \
|
||||
"github:NixOS/nixpkgs/${NIXPKGS_COMMIT}#yt-dlp"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps from the app-local build context.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
|
||||
COPY vendor/discord-video-stream/package.json ./vendor/discord-video-stream/
|
||||
COPY vendor/discord.js-selfbot-v13/package.json ./vendor/discord.js-selfbot-v13/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run prepare:vendor
|
||||
RUN pnpm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
||||
@@ -0,0 +1,380 @@
|
||||
# Discord Moderation Watcher Bot - Microservices Architecture
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Node.js 20+
|
||||
- pnpm 11+
|
||||
- Discord bot token
|
||||
- OpenAI API key
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Create `.env.local` in the root directory:
|
||||
|
||||
```bash
|
||||
# Discord Configuration
|
||||
DISCORD_TOKEN=your_discord_token_here
|
||||
MONITOR_GUILD_ID=your_guild_id_here
|
||||
|
||||
# AI Configuration
|
||||
AI_LLM_API_KEY=your_openai_api_key_here
|
||||
|
||||
# Optional: Database URL (defaults to PostgreSQL in Docker)
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bete
|
||||
|
||||
# Optional: Redis URL (defaults to Redis in Docker)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
### Local Development with Docker Compose
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop all services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild services
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
**Services will be available at:**
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:3001
|
||||
- Backend WebSocket: ws://localhost:3001
|
||||
- PostgreSQL: localhost:5432
|
||||
- Redis: localhost:6379
|
||||
|
||||
### Local Development without Docker
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run database migrations
|
||||
pnpm run db:migrate
|
||||
|
||||
# Start all services in separate terminals
|
||||
|
||||
# Terminal 1: Backend
|
||||
cd services/backend
|
||||
pnpm run dev
|
||||
|
||||
# Terminal 2: Discord Gateway
|
||||
cd services/discord-gateway
|
||||
pnpm run dev
|
||||
|
||||
# Terminal 3: Frontend
|
||||
cd services/frontend
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### 3 Independent Microservices
|
||||
|
||||
#### 1. Frontend Service (`services/frontend/`)
|
||||
- **Tech:** React 19, Vite, TanStack Query, WebSocket
|
||||
- **Port:** 5173 (dev) / served by Backend (prod)
|
||||
- **Responsibilities:**
|
||||
- Dashboard UI (analytics, messages, voice, media)
|
||||
- Real-time WebSocket connection to Backend
|
||||
- API calls to Backend REST endpoints
|
||||
- State management (React Query)
|
||||
|
||||
#### 2. Backend Service (`services/backend/`)
|
||||
- **Tech:** Express, Drizzle ORM, PostgreSQL, Redis
|
||||
- **Port:** 3001
|
||||
- **Responsibilities:**
|
||||
- REST API endpoints (`/api/*`)
|
||||
- WebSocket server for real-time updates
|
||||
- Database operations (PostgreSQL)
|
||||
- Event orchestration from Discord Gateway
|
||||
- Static file serving (built Frontend)
|
||||
- Admin authentication
|
||||
|
||||
**Modular MVC Structure:**
|
||||
```
|
||||
services/backend/src/
|
||||
├── shared/
|
||||
│ ├── database/ → Drizzle ORM setup
|
||||
│ ├── config/ → Environment config
|
||||
│ ├── errors/ → Custom error classes
|
||||
│ ├── middlewares/ → Express middlewares
|
||||
│ ├── logger/ → Logging utilities
|
||||
│ └── utils/ → Shared utilities
|
||||
├── modules/
|
||||
│ ├── messages/ → Message CRUD
|
||||
│ ├── analytics/ → Analytics queries
|
||||
│ ├── media/ → Media management
|
||||
│ ├── voice/ → Voice recordings
|
||||
│ └── health/ → Health checks
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
#### 3. Discord Gateway Service (`services/discord-gateway/`)
|
||||
- **Tech:** discord.js-selfbot-v13, @discordjs/voice, OpenAI API
|
||||
- **Port:** None (internal service, no HTTP)
|
||||
- **Responsibilities:**
|
||||
- Discord client connection
|
||||
- Message capture (create/edit/delete)
|
||||
- Voice channel recording
|
||||
- AI moderation analysis
|
||||
- Attachment upload
|
||||
- Event publishing to Backend (Redis pub/sub)
|
||||
|
||||
**Modular MVC Structure:**
|
||||
```
|
||||
services/discord-gateway/src/
|
||||
├── shared/
|
||||
│ ├── database/ → Drizzle ORM setup
|
||||
│ ├── config/ → Environment config
|
||||
│ ├── errors/ → Custom error classes
|
||||
│ ├── logger/ → Logging utilities
|
||||
│ └── utils/ → Shared utilities
|
||||
├── modules/
|
||||
│ ├── message-capture/ → Message listeners
|
||||
│ ├── voice-recording/ → Voice recording
|
||||
│ ├── ai-moderation/ → AI analysis
|
||||
│ ├── attachment-upload/ → File uploads
|
||||
│ └── event-broadcaster/ → Redis pub/sub
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### Shared Package (`packages/shared/`)
|
||||
- **Types:** Common interfaces and data models
|
||||
- **Errors:** Custom error classes
|
||||
- **Logger:** Pino logger setup
|
||||
- **Utils:** Pagination, validation, helpers
|
||||
|
||||
### Communication Patterns
|
||||
|
||||
**Frontend ↔ Backend:**
|
||||
- REST API: `GET/POST /api/*` (HTTP)
|
||||
- WebSocket: Real-time updates (JSON messages)
|
||||
- Auth: Admin password header
|
||||
|
||||
**Backend ↔ Discord Gateway:**
|
||||
- Redis pub/sub (low-latency, decoupled)
|
||||
- Events: `discord:message:created`, `discord:voice:started`, etc.
|
||||
- Backend subscribes and broadcasts to Frontend via WebSocket
|
||||
|
||||
**Shared Resources:**
|
||||
- PostgreSQL: Both Backend and Discord Gateway
|
||||
- Redis: Pub/sub and caching
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. **Create module structure** (if new feature):
|
||||
```bash
|
||||
mkdir -p services/backend/src/modules/feature/{routes,controllers,services,repositories,schemas}
|
||||
```
|
||||
|
||||
2. **Define schema** (`feature.schema.ts`):
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createFeatureSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
3. **Create repository** (`feature.repository.ts`):
|
||||
```typescript
|
||||
export async function createFeature(data: CreateFeatureInput) {
|
||||
return db.insert(features).values(data).returning();
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create service** (`feature.service.ts`):
|
||||
```typescript
|
||||
export async function createFeatureService(data: CreateFeatureInput) {
|
||||
// Business logic, validation, orchestration
|
||||
return createFeature(data);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Create controller** (`feature.controller.ts`):
|
||||
```typescript
|
||||
export async function createFeatureController(req: Request, res: Response) {
|
||||
const data = createFeatureSchema.parse(req.body);
|
||||
const result = await createFeatureService(data);
|
||||
res.json(result);
|
||||
}
|
||||
```
|
||||
|
||||
6. **Create route** (`feature.route.ts`):
|
||||
```typescript
|
||||
router.post('/features', createFeatureController);
|
||||
```
|
||||
|
||||
### Adding a New Discord Event
|
||||
|
||||
1. **Create module** in `services/discord-gateway/src/modules/event-name/`
|
||||
|
||||
2. **Register listener** in `index.ts`:
|
||||
```typescript
|
||||
client.on('eventName', async (data) => {
|
||||
await handleEvent(data);
|
||||
publishEvent('discord:event:name', data);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Publish to Redis**:
|
||||
```typescript
|
||||
import { redis } from '../shared/redis';
|
||||
|
||||
redis.publish('discord:event:name', JSON.stringify(data));
|
||||
```
|
||||
|
||||
4. **Subscribe in Backend** (`services/backend/src/ws/server.ts`):
|
||||
```typescript
|
||||
redis.subscribe('discord:event:name', (message) => {
|
||||
broadcastToClients({ type: 'event_name', data: JSON.parse(message) });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
### Run Tests for Specific Service
|
||||
```bash
|
||||
cd services/backend
|
||||
pnpm run test
|
||||
|
||||
cd services/discord-gateway
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build Docker Images
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
### Push to Container Registry
|
||||
```bash
|
||||
docker tag bete-backend ghcr.io/username/bete-backend:latest
|
||||
docker push ghcr.io/username/bete-backend:latest
|
||||
```
|
||||
|
||||
### Deploy to Production
|
||||
See `.github/workflows/deploy.yml` for GitHub Actions CI/CD pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend can't connect to PostgreSQL
|
||||
```bash
|
||||
# Check PostgreSQL is running
|
||||
docker-compose ps postgres
|
||||
|
||||
# Check connection string
|
||||
echo $DATABASE_URL
|
||||
|
||||
# Verify credentials
|
||||
psql -h localhost -U postgres -d bete
|
||||
```
|
||||
|
||||
### Discord Gateway not receiving events
|
||||
```bash
|
||||
# Check Redis connection
|
||||
redis-cli ping
|
||||
|
||||
# Check Discord token
|
||||
echo $DISCORD_TOKEN
|
||||
|
||||
# View logs
|
||||
docker-compose logs discord-gateway
|
||||
```
|
||||
|
||||
### Frontend can't connect to Backend
|
||||
```bash
|
||||
# Check Backend is running
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# Check WebSocket connection
|
||||
# Open browser DevTools → Network → WS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
GET /health
|
||||
```
|
||||
|
||||
### Messages
|
||||
```bash
|
||||
GET /api/messages?channel=<id>&type=text|image
|
||||
POST /api/messages (admin only)
|
||||
```
|
||||
|
||||
### Analytics
|
||||
```bash
|
||||
GET /api/analytics
|
||||
```
|
||||
|
||||
### Voice Recordings
|
||||
```bash
|
||||
GET /api/recordings
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
```
|
||||
message_created
|
||||
message_updated
|
||||
message_deleted
|
||||
attachment_uploaded
|
||||
user_state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make changes following Modular MVC pattern
|
||||
3. Run tests and linting
|
||||
4. Submit PR with description
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -3,6 +3,9 @@
|
||||
"includes": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"services/**/*.ts",
|
||||
"services/**/*.tsx",
|
||||
"packages/**/*.ts",
|
||||
"*.json",
|
||||
"*.ts",
|
||||
"!vendor/**",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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();
|
||||
@@ -1,28 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: ${IMAGE_NAME:-ghcr.io/mytheclipse/gmw:latest}
|
||||
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.my.id`)"
|
||||
- "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
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Component, Suspense, lazy } from "react";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { MessagesPanel } from "./features/messages";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { mergeMessages, useMessages } from "./features/messages/hooks/useMessages";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { getAppConfig, type MessageRecord, type ActiveSpeaker, type MediaState } from "./shared/api/client";
|
||||
import { Skeleton } from "./shared/ui";
|
||||
|
||||
const AnalyticsPanel = lazy(() => import("./features/analytics").then((module) => ({ default: module.AnalyticsPanel })));
|
||||
|
||||
class AnalyticsErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() { return { hasError: true }; }
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return <div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">Analytics failed to load. The rest of the dashboard is still available.</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||
|
||||
const audio = useAudioPlayback();
|
||||
const activeTab = uiState.activeTab || "live";
|
||||
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
const selectedTextGuild = monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||
const monitorGuild = useMemo(() => (monitorGuildId ? voice.guilds.find((g) => g.id === monitorGuildId) : undefined), [monitorGuildId, voice.guilds]);
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onBinary: audio.handleIncomingPcm,
|
||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||
onMessageCreated: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onMessageUpdated: (m) => {
|
||||
const d = m as Partial<MessageRecord> & { id: string };
|
||||
messages.setMessages((prev) => prev.map((i) => i.id === d.id ? { ...i, ...d } : i));
|
||||
},
|
||||
onMessageDeleted: (m) => {
|
||||
const d = m as { id: string };
|
||||
messages.setMessages((prev) => prev.map((i) => i.id === d.id ? { ...i, type: "deleted" as const } : i));
|
||||
},
|
||||
onMessageAnalyzed: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||
onMediaState: (state) => media.setMediaState(state as MediaState),
|
||||
onVoiceRecordingUploaded: (d) => window.dispatchEvent(new CustomEvent("voice_recording_uploaded", { detail: d })),
|
||||
});
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
useEffect(() => {
|
||||
getAppConfig().then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
patchUIState({ selectedTextGuild: c.monitorGuildId, selectedAnalyticsGuild: c.monitorGuildId, selectedTextChannel: "", selectedAnalyticsChannel: "" });
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, [patchUIState]);
|
||||
|
||||
useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
|
||||
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
|
||||
|
||||
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed
|
||||
useEffect(() => {
|
||||
if (!selectedTextChannel) return;
|
||||
const interval = setInterval(() => {
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTextChannel, messages.fetchMessages]);
|
||||
|
||||
return (
|
||||
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
|
||||
{activeTab === "live" ? (
|
||||
!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<LivePanel
|
||||
guilds={voice.guilds} voiceChannels={voice.voiceChannels} selectedGuild={selectedVoiceGuild} selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||
status={voice.voiceStatus} voiceLoading={voice.loading} activeSpeakers={activeSpeakers}
|
||||
levels={audio.levels} isListening={audio.isListening} isStreaming={transmit.isStreaming}
|
||||
mediaState={media.mediaState} mediaLoading={media.loading}
|
||||
onGuildChange={(id) => patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })}
|
||||
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||
onJoin={() => voice.joinVoice(selectedVoiceGuild, uiState.selectedVoiceChannel || "")}
|
||||
onDisconnect={() => voice.leaveVoice()}
|
||||
onListenToggle={audio.toggleListening} onStreamingToggle={transmit.toggle}
|
||||
onQueueMusic={(s) => media.enqueue(s, "music")} onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||
onSkip={media.skip} onStop={media.stop} onVolumeChange={media.setVolume}
|
||||
/>
|
||||
)
|
||||
) : activeTab === "messages" ? (
|
||||
<MessagesPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []} channels={voice.textChannels}
|
||||
selectedGuild={selectedTextGuild} selectedChannel={selectedTextChannel}
|
||||
messages={messages.messages}
|
||||
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
|
||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsErrorBoundary>
|
||||
<Suspense fallback={<div className="flex flex-col gap-4">{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}<Skeleton className="h-64 w-full rounded-xl" /></div>}>
|
||||
<AnalyticsPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []} channels={voice.textChannels}
|
||||
selectedGuild={uiState.selectedAnalyticsGuild || selectedTextGuild || ""}
|
||||
selectedChannel={uiState.selectedAnalyticsChannel || selectedTextChannel || ""}
|
||||
onGuildChange={(id) => patchUIState({ selectedAnalyticsGuild: id, selectedAnalyticsChannel: "" })}
|
||||
onChannelChange={(id) => patchUIState({ selectedAnalyticsChannel: id })}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalyticsErrorBoundary>
|
||||
)}
|
||||
<MobileTabBar activeTab={activeTab} onTabChange={(tab) => patchUIState({ activeTab: tab })} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { ModerationBreakdown } from "../../../shared/api/client";
|
||||
import { Card, CardContent, Skeleton } from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
messages: ModerationBreakdown | null;
|
||||
activeUsersCount: number;
|
||||
totalChannels: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({ messages, activeUsersCount, totalChannels, loading }: SummaryCardsProps) {
|
||||
const avgPerHour = messages ? Math.round(messages.total / Math.max(1, 24)) : 0;
|
||||
const cleanPct = messages && messages.total > 0 ? Math.round((messages.clean / messages.total) * 100) : 0;
|
||||
const warnedPct = messages && messages.total > 0 ? Math.round((messages.warned / messages.total) * 100) : 0;
|
||||
const flaggedPct = messages && messages.total > 0 ? Math.round((messages.flagged / messages.total) * 100) : 0;
|
||||
|
||||
const cards = [
|
||||
{ label: "Total Pesan", value: formatNum(messages?.total), accent: "text-foreground" },
|
||||
{ label: "Rata-rata/jam", value: formatNum(avgPerHour), accent: "text-muted-foreground" },
|
||||
{ label: "Clean", value: cleanPct > 0 ? `${cleanPct}%` : "—", accent: "text-emerald-400" },
|
||||
{ label: "Warned", value: warnedPct > 0 ? `${warnedPct}%` : "—", accent: "text-amber-400" },
|
||||
{ label: "Flagged", value: flaggedPct > 0 ? `${flaggedPct}%` : "—", accent: "text-red-400" },
|
||||
{ label: "Pending", value: formatNum(messages?.pending), accent: "text-slate-400" },
|
||||
{ label: "User Aktif", value: formatNum(activeUsersCount), accent: "text-violet-400" },
|
||||
{ label: "Channel", value: formatNum(totalChannels), accent: "text-blue-400" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="overflow-hidden glass border-white/5">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</div>
|
||||
<div className={cn("mt-1 font-mono text-lg font-bold tabular-nums", card.accent)}>
|
||||
{loading ? (
|
||||
<Skeleton className="h-7 w-12 mt-1" />
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNum(v: number | undefined | null): string {
|
||||
if (v == null || v === 0) return "—";
|
||||
return v.toLocaleString("id-ID");
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../shared/api/client";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { TrendChart } from "./components/TrendChart";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
import { TopicList } from "./components/TopicList";
|
||||
import { UserTable } from "./components/UserTable";
|
||||
import { ViolatorTable } from "./components/ViolatorTable";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds, channels, selectedGuild, selectedChannel,
|
||||
onGuildChange, onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
const analytics = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours });
|
||||
|
||||
const { hourly, topics, topUsers, activeUsersCount, totalChannels, violators, trend, heatmap, isLoading, isFetching, error, refresh, refreshViolators, messages: analyticsMessages } = analytics;
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !analyticsMessages) {
|
||||
return <div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">{error}</div>;
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
return <div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8"><p className="text-sm text-muted-foreground">Pilih guild untuk melihat analitik.</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlBar guilds={guilds} channels={channels} selectedGuild={selectedGuild} selectedChannel={selectedChannel} hours={hours} isFetching={isFetching} onGuildChange={onGuildChange} onChannelChange={onChannelChange} onHoursChange={setHours} onRefresh={() => { refresh(); refreshViolators(); }} />
|
||||
<SummaryCards messages={analyticsMessages} activeUsersCount={activeUsersCount} totalChannels={totalChannels} loading={loading} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1"><TopicList topics={topics} loading={loading} /></div>
|
||||
</div>
|
||||
{hours >= 48 && <TrendChart trend={trend} loading={loading} />}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1"><UserTable users={topUsers} loading={loading} /></div>
|
||||
</div>
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
import { Radio, Headphones } from "lucide-react";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
}
|
||||
|
||||
export function VoiceConnectionCard({
|
||||
guilds, voiceChannels, selectedGuild, selectedChannel,
|
||||
status, voiceLoading, isListening, isStreaming,
|
||||
onGuildChange, onChannelChange, onJoin, onDisconnect,
|
||||
onListenToggle, onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Radio className="h-5 w-5" /> Voice Bridge
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Join a Discord voice channel, listen, and transmit audio.</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Guild</label>
|
||||
<Select value={selectedGuild} onChange={(e) => onGuildChange(e.target.value)} placeholder="Select guild" options={guilds.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Voice Channel</label>
|
||||
<Select value={selectedChannel} onChange={(e) => onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button disabled={!selectedGuild || !selectedChannel || voiceLoading} onClick={onJoin}>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={!status.connected || voiceLoading} onClick={onDisconnect}>Disconnect</Button>
|
||||
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
|
||||
<Headphones className="mr-1.5 h-4 w-4" /> {isListening ? "Stop Listening" : "Listen"}
|
||||
</Button>
|
||||
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
|
||||
<Radio className="mr-1.5 h-4 w-4" /> {isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { Channel, Guild, MessageRecord } from "../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Badge, Button, Input, Select, Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||
import { MessageFeed } from "./components/MessageFeed";
|
||||
import { ImageGrid } from "./components/ImageGrid";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
messages: MessageRecord[];
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
}
|
||||
|
||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||
|
||||
export function MessagesPanel({
|
||||
guilds, channels, selectedGuild, selectedChannel,
|
||||
messages, onGuildChange, onChannelChange, onReanalyze,
|
||||
onLoadMore, hasMore, loadingMore,
|
||||
}: MessagesPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [aiFilter, setAiFilter] = useState<AiFilter>("all");
|
||||
const [viewTab, setViewTab] = useState<"all" | "images">("all");
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) { setSearchResults([]); setShowSearch(false); return; }
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ q: searchQuery, ...(selectedChannel && { channelId: selectedChannel }), limit: "50" });
|
||||
const response = await fetch(`/api/analysis/search?${params}`);
|
||||
if (!response.ok) throw new Error("Search failed");
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results || []);
|
||||
setShowSearch(true);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const base = showSearch ? searchResults : messages;
|
||||
return {
|
||||
total: base.length,
|
||||
clean: base.filter((m) => m.ai_status === "clean").length,
|
||||
warn: base.filter((m) => m.ai_status === "warn").length,
|
||||
flagged: base.filter((m) => m.ai_status === "flagged").length,
|
||||
error: base.filter((m) => m.ai_status === "error").length,
|
||||
pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status).length,
|
||||
deleted: base.filter((m) => m.deleted_at).length,
|
||||
edited: base.filter((m) => m.edited_at).length,
|
||||
};
|
||||
}, [messages, searchResults, showSearch]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
const base = showSearch ? searchResults : messages;
|
||||
if (aiFilter === "all") return base;
|
||||
return base.filter((m) => {
|
||||
const status = m.ai_status ?? "pending";
|
||||
if (aiFilter === "pending") return status === "pending" || status === null || status === undefined;
|
||||
return status === aiFilter;
|
||||
});
|
||||
}, [messages, searchResults, showSearch, aiFilter]);
|
||||
|
||||
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={(e) => onGuildChange(e.target.value)} placeholder="Select text guild" options={guilds.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
<Select value={selectedChannel} onChange={(e) => onChannelChange(e.target.value)} placeholder="Select channel or thread" options={channels.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{stats.total > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">{stats.total} total{hasMore && !showSearch ? "+" : ""}</Badge>
|
||||
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
||||
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
||||
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
||||
<Badge variant="outline" className="text-xs text-orange-400 border-orange-400/30">{stats.error} error</Badge>
|
||||
<Badge variant="outline" className="text-xs">{stats.pending} pending</Badge>
|
||||
{stats.deleted > 0 && <Badge variant="destructive" className="text-xs">{stats.deleted} deleted</Badge>}
|
||||
{stats.edited > 0 && <Badge variant="outline" className="text-xs">{stats.edited} edited</Badge>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder="Search message content..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} disabled={isSearching} />
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={isSearching || !searchQuery.trim()} size="sm">{isSearching ? "Searching..." : "Search"}</Button>
|
||||
{showSearch && (
|
||||
<Button variant="outline" size="sm" onClick={() => { setShowSearch(false); setSearchResults([]); setSearchQuery(""); }}>
|
||||
<X className="mr-1 h-3 w-3" /> Clear
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
{(["all", "clean", "warn", "flagged", "error", "pending"] as AiFilter[]).map((f) => (
|
||||
<button key={f} onClick={() => setAiFilter(f)} className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${aiFilter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}</div>
|
||||
)}
|
||||
|
||||
<Tabs value={viewTab} onValueChange={(v) => setViewTab(v as "all" | "images")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">{showSearch ? `Search (${filteredMessages.length})` : `All (${filteredMessages.length})`}</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all">
|
||||
<MessageFeed
|
||||
messages={filteredMessages}
|
||||
onReanalyze={onReanalyze}
|
||||
emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={filteredMessages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
|
||||
export function useAudioPlayback() {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const userTimelinesRef = useRef(new Map<number, number>());
|
||||
|
||||
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
||||
const headerView = new DataView(data, 0, 4);
|
||||
const userIdHash = headerView.getInt32(0, true);
|
||||
const audioData = data.slice(4);
|
||||
const int16Array = new Int16Array(audioData);
|
||||
let sum = 0;
|
||||
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
||||
const average = int16Array.length ? sum / int16Array.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),
|
||||
),
|
||||
);
|
||||
|
||||
const audioContext = audioContextRef.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 / SAMPLE_RATE, 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 toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
await audioContextRef.current?.suspend();
|
||||
userTimelinesRef.current.clear();
|
||||
setIsListening(false);
|
||||
return;
|
||||
}
|
||||
const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||
await audioContextRef.current.resume();
|
||||
setIsListening(true);
|
||||
}, [isListening]);
|
||||
|
||||
return { isListening, levels, handleIncomingPcm, toggleListening, audioContextRef };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { UIState } from "../../entities/ui/types";
|
||||
import { useLocalStorage, uiStateValidator } from "./useLocalStorage";
|
||||
|
||||
export function useUIState() {
|
||||
const { value: uiState, setValue: setUIState } = useLocalStorage<UIState>("bete-dashboard-ui-state", uiStateValidator());
|
||||
|
||||
const patchUIState = useCallback((patch: Partial<UIState>) => {
|
||||
setUIState((prev) => ({ ...prev, ...patch }));
|
||||
}, [setUIState]);
|
||||
|
||||
return { uiState, setUIState, patchUIState, loading: false, error: null };
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy workspace files
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY package.json .
|
||||
|
||||
# Copy shared package
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
# Copy backend service
|
||||
COPY services/backend ./services/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build backend
|
||||
RUN pnpm run build:server
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start backend
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy workspace files
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY package.json .
|
||||
|
||||
# Copy shared package
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
# Copy discord gateway service
|
||||
COPY services/discord-gateway ./services/discord-gateway
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build discord gateway
|
||||
RUN pnpm run build
|
||||
|
||||
# Create recordings directory
|
||||
RUN mkdir -p /app/recordings
|
||||
|
||||
# Start discord gateway
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy workspace files
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY package.json .
|
||||
|
||||
# Copy shared package
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
# Copy frontend service
|
||||
COPY services/frontend ./services/frontend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build frontend
|
||||
RUN pnpm run build:web
|
||||
|
||||
# Expose port (served by backend)
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["pnpm", "run", "dev:web"]
|
||||
@@ -0,0 +1,67 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend Service (REST API + WebSocket via Traefik)
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/docker/Dockerfile.backend
|
||||
container_name: bete-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../../.env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
WEBSERVER_PORT: 3000
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.bete-backend.rule=Host(`imphnen.asepharyana.my.id`) && PathPrefix(`/api`, `/ws`)"
|
||||
- "traefik.http.routers.bete-backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.bete-backend.tls=true"
|
||||
- "traefik.http.services.bete-backend.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- discord-gateway
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
# Discord Gateway Service (Event capture and processing — no HTTP)
|
||||
discord-gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/docker/Dockerfile.discord-gateway
|
||||
container_name: bete-discord-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../../.env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- ../recordings:/app/recordings
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
# Frontend Service (React Dashboard via Traefik)
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/docker/Dockerfile.frontend
|
||||
container_name: bete-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_URL: https://imphnen.asepharyana.my.id
|
||||
VITE_WS_URL: wss://imphnen.asepharyana.my.id
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.bete-frontend.rule=Host(`imphnen.asepharyana.my.id`)"
|
||||
- "traefik.http.routers.bete-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.bete-frontend.tls=true"
|
||||
- "traefik.http.services.bete-frontend.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
networks:
|
||||
app-shared-net:
|
||||
name: app-shared-net
|
||||
external: true
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@bete/shared",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared utilities, types, and errors for Bete microservices",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./types": "./dist/types/index.js",
|
||||
"./errors": "./dist/errors/index.js",
|
||||
"./logger": "./dist/logger/index.js",
|
||||
"./utils": "./dist/utils/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Custom error classes for all services
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("VALIDATION_ERROR", 400, message, details);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id?: string) {
|
||||
super("NOT_FOUND", 404, `${resource} not found${id ? `: ${id}` : ""}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super("UNAUTHORIZED", 401, message);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = "Forbidden") {
|
||||
super("FORBIDDEN", 403, message);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFLICT", 409, message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends AppError {
|
||||
constructor(
|
||||
message = "Internal server error",
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super("INTERNAL_SERVER_ERROR", 500, message, details);
|
||||
this.name = "InternalServerError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DATABASE_ERROR", 500, message, details);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFIG_ERROR", 500, message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DISCORD_ERROR", 500, message, details);
|
||||
this.name = "DiscordError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends AppError {
|
||||
constructor(operation: string) {
|
||||
super("TIMEOUT", 504, `${operation} timed out`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./errors/index.js";
|
||||
export * from "./logger/index.js";
|
||||
export * from "./types/index.js";
|
||||
export * from "./utils/index.js";
|
||||
@@ -0,0 +1,25 @@
|
||||
import pino from "pino";
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
export function createLogger(context: string) {
|
||||
return pino({
|
||||
name: context,
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} as pino.LoggerOptions);
|
||||
}
|
||||
|
||||
export function createChildLogger(context: string) {
|
||||
return createLogger(context);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Shared types for all services
|
||||
export interface AppConfig {
|
||||
NODE_ENV: "development" | "production" | "test";
|
||||
LOG_LEVEL: string;
|
||||
VERBOSE: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
DATABASE_URL: string;
|
||||
AUTO_MIGRATE_ON_STARTUP: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordConfig {
|
||||
DISCORD_TOKEN: string;
|
||||
MONITOR_GUILD_ID: string;
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
AI_LLM_API_KEY: string;
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
REDIS_URL: string;
|
||||
}
|
||||
|
||||
export interface WebServerConfig {
|
||||
WEBSERVER_PORT: number;
|
||||
ADMIN_PASSWORD: string;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
discordUrl: string;
|
||||
uploadedUrl?: string;
|
||||
uploadStatus: "pending" | "uploaded" | "failed";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface VoiceSegment {
|
||||
userId: string;
|
||||
sessionStart: number;
|
||||
segmentIndex: number;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
totalMessages: number;
|
||||
totalAttachments: number;
|
||||
totalVoiceSegments: number;
|
||||
activeUsers: number;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Utility functions shared across services
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeString(str: string): string {
|
||||
return str.replace(/[<>]/g, "").trim().substring(0);
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export function calculatePagination(
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResponse<never> {
|
||||
return {
|
||||
data: [],
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
pages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOffset(page: number, limit: number): number {
|
||||
return (page - 1) * limit;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+300
-6
@@ -166,6 +166,226 @@ importers:
|
||||
specifier: latest
|
||||
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
pino:
|
||||
specifier: ^9.0.0
|
||||
version: 9.14.0
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
services/backend:
|
||||
dependencies:
|
||||
'@discordjs/voice':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.0.8)
|
||||
'@types/pg':
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
axios:
|
||||
specifier: ^1.16.1
|
||||
version: 1.16.1
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
helmet:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
ioredis:
|
||||
specifier: ^5.11.0
|
||||
version: 5.11.0
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
pino:
|
||||
specifier: ^9.6.0
|
||||
version: 9.14.0
|
||||
pino-http:
|
||||
specifier: ^10.3.0
|
||||
version: 10.5.0
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
ws:
|
||||
specifier: ^8.20.1
|
||||
version: 8.20.1
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: latest
|
||||
version: 2.4.16
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
tsx:
|
||||
specifier: ^4.22.2
|
||||
version: 4.22.2
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: latest
|
||||
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
||||
|
||||
services/discord-gateway:
|
||||
dependencies:
|
||||
'@discordjs/opus':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
'@discordjs/voice':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.0.8)
|
||||
'@snazzah/davey':
|
||||
specifier: ^0.1.11
|
||||
version: 0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
discord.js-selfbot-v13:
|
||||
specifier: workspace:*
|
||||
version: link:../../vendor/discord.js-selfbot-v13
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
|
||||
ioredis:
|
||||
specifier: ^5.11.0
|
||||
version: 5.11.0
|
||||
libsodium-wrappers:
|
||||
specifier: ^0.8.4
|
||||
version: 0.8.4
|
||||
openai:
|
||||
specifier: ^6.38.0
|
||||
version: 6.38.0(ws@8.20.1)(zod@4.4.3)
|
||||
opusscript:
|
||||
specifier: ^0.0.8
|
||||
version: 0.0.8
|
||||
p-limit:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
p-retry:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
piscina:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4
|
||||
prism-media:
|
||||
specifier: 2.0.0-alpha.0
|
||||
version: 2.0.0-alpha.0
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
winston:
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: latest
|
||||
version: 2.4.16
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.0
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
tsx:
|
||||
specifier: ^4.22.2
|
||||
version: 4.22.2
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: latest
|
||||
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
||||
|
||||
services/frontend:
|
||||
dependencies:
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.14
|
||||
version: 5.100.14(react@19.2.6)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
lucide-react:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(react@19.2.6)
|
||||
react:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
react-dom:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6(react@19.2.6)
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: latest
|
||||
version: 2.4.16
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.14)
|
||||
postcss:
|
||||
specifier: ^8.5.14
|
||||
version: 8.5.14
|
||||
tailwindcss:
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^8.0.13
|
||||
version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)
|
||||
|
||||
vendor/discord-video-stream:
|
||||
dependencies:
|
||||
'@lng2004/node-datachannel':
|
||||
@@ -1405,6 +1625,9 @@ packages:
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -3750,6 +3973,10 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3932,6 +4159,19 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-http@10.5.0:
|
||||
resolution: {integrity: sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==}
|
||||
|
||||
pino-std-serializers@7.1.0:
|
||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||
|
||||
pino@9.14.0:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
hasBin: true
|
||||
|
||||
piscina@5.1.4:
|
||||
resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==}
|
||||
engines: {node: '>=20.x'}
|
||||
@@ -4022,6 +4262,9 @@ packages:
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4072,6 +4315,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
quick-lru@4.0.1:
|
||||
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4119,6 +4365,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4523,6 +4773,9 @@ packages:
|
||||
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||
engines: {node: '>=0.2.6'}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
@@ -5880,6 +6133,8 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -6286,7 +6541,7 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
@@ -6295,7 +6550,7 @@ snapshots:
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
@@ -6312,7 +6567,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
'@types/qs': 6.15.1
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -6325,7 +6580,7 @@ snapshots:
|
||||
|
||||
'@types/fluent-ffmpeg@2.1.28':
|
||||
dependencies:
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
@@ -6374,12 +6629,12 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.8.0
|
||||
'@types/node': 25.9.0
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
@@ -8079,6 +8334,8 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -8245,6 +8502,33 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-http@10.5.0:
|
||||
dependencies:
|
||||
get-caller-file: 2.0.5
|
||||
pino: 9.14.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
|
||||
pino-std-serializers@7.1.0: {}
|
||||
|
||||
pino@9.14.0:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 3.1.0
|
||||
|
||||
piscina@5.1.4:
|
||||
optionalDependencies:
|
||||
'@napi-rs/nice': 1.1.1
|
||||
@@ -8334,6 +8618,8 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prom-client@15.1.3:
|
||||
@@ -8388,6 +8674,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
quick-lru@4.0.1: {}
|
||||
|
||||
quick-lru@7.3.0: {}
|
||||
@@ -8446,6 +8734,8 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
@@ -8887,6 +9177,10 @@ snapshots:
|
||||
|
||||
thirty-two@1.0.2: {}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
thunky@1.1.0: {}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
packages:
|
||||
- .
|
||||
- services/frontend
|
||||
- services/backend
|
||||
- services/discord-gateway
|
||||
- packages/shared
|
||||
- vendor/discord-video-stream
|
||||
- vendor/discord.js-selfbot-v13
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Backend Service Architecture Map
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
services/backend/
|
||||
├── src/
|
||||
│ ├── shared/ # Shared infrastructure (no business logic)
|
||||
│ │ ├── config/
|
||||
│ │ │ └── index.ts # Zod-validated environment config
|
||||
│ │ ├── database/
|
||||
│ │ │ └── index.ts # Drizzle ORM initialization & connection pool
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── index.ts # Custom error classes (AppError, ValidationError, etc.)
|
||||
│ │ ├── logger/
|
||||
│ │ │ └── index.ts # Pino logger with child context support
|
||||
│ │ ├── middlewares/
|
||||
│ │ │ └── index.ts # Express middleware (errorHandler, asyncHandler, adminAuth)
|
||||
│ │ └── utils/ # Utility functions (placeholder)
|
||||
│ │
|
||||
│ ├── modules/ # Feature modules (Modular MVC pattern)
|
||||
│ │ ├── messages/
|
||||
│ │ │ ├── messages.schema.ts # Zod validation schemas (MessageQuery, MessageCreate, MessageUpdate)
|
||||
│ │ │ ├── messages.repository.ts # Database operations (findMany, findById, create, update, delete)
|
||||
│ │ │ ├── messages.service.ts # Business logic (validation, orchestration)
|
||||
│ │ │ ├── messages.controller.ts # Request handlers (parse → service → response)
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts # Express router (GET /api/messages, etc.)
|
||||
│ │ │
|
||||
│ │ ├── analytics/
|
||||
│ │ │ ├── analytics.schema.ts
|
||||
│ │ │ ├── analytics.repository.ts
|
||||
│ │ │ ├── analytics.service.ts
|
||||
│ │ │ ├── analytics.controller.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── media/
|
||||
│ │ │ ├── media.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── voice/
|
||||
│ │ │ ├── voice.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── health/
|
||||
│ │ ├── health.schema.ts
|
||||
│ │ ├── health.repository.ts
|
||||
│ │ ├── health.service.ts
|
||||
│ │ ├── health.controller.ts
|
||||
│ │ └── routes/
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── http/
|
||||
│ │ ├── app.ts # Express app factory (middleware, routes, error handler)
|
||||
│ │ └── server.ts # HTTP server startup (port binding, graceful shutdown)
|
||||
│ │
|
||||
│ ├── ws/ # WebSocket server (placeholder for real-time updates)
|
||||
│ │ └── server.ts # Redis pub/sub listener for Discord Gateway events
|
||||
│ │
|
||||
│ └── index.ts # Entry point (main function, signal handlers)
|
||||
│
|
||||
├── package.json # Backend dependencies
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── README.md # Backend-specific documentation
|
||||
```
|
||||
|
||||
## Layer Separation
|
||||
|
||||
### 1. Controller Layer
|
||||
**File:** `modules/*/[module].controller.ts`
|
||||
**Responsibility:** HTTP request handling only
|
||||
- Parse request (query, params, body)
|
||||
- Validate using Zod schemas
|
||||
- Call service methods
|
||||
- Return HTTP response (200, 400, 404, 500)
|
||||
- **No database calls**
|
||||
- **No business logic**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
export function handleListMessages(req: Request, res: Response, next: NextFunction) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
const result = await messagesService.listMessages(query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service Layer
|
||||
**File:** `modules/*/[module].service.ts`
|
||||
**Responsibility:** Business logic and orchestration
|
||||
- Validate input (throw ValidationError if invalid)
|
||||
- Orchestrate repository calls
|
||||
- Apply business rules
|
||||
- Handle cross-cutting concerns (auth, permissions)
|
||||
- **No database calls directly**
|
||||
- **No HTTP request/response handling**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
async listMessages(query: MessageQuery) {
|
||||
if (!query.channelId && !query.guildId) {
|
||||
throw new ValidationError("Either channelId or guildId is required");
|
||||
}
|
||||
return messagesRepository.findMany(query);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Repository Layer
|
||||
**File:** `modules/*/[module].repository.ts`
|
||||
**Responsibility:** All database operations
|
||||
- Execute Drizzle ORM queries
|
||||
- Handle database errors
|
||||
- Return raw data (no transformation)
|
||||
- **No business logic**
|
||||
- **No HTTP handling**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
async findMany(query: MessageQuery) {
|
||||
const db = getDatabase();
|
||||
return db.select().from(messagesTable).where(...).limit(query.limit);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Schema Layer
|
||||
**File:** `modules/*/[module].schema.ts`
|
||||
**Responsibility:** Zod validation schemas
|
||||
- Define request/response types
|
||||
- Validate at controller entry point
|
||||
- Export TypeScript types
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
export const messageQuerySchema = z.object({
|
||||
channelId: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
});
|
||||
```
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
| Module | Purpose | Routes |
|
||||
|--------|---------|--------|
|
||||
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
|
||||
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
|
||||
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
|
||||
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
|
||||
| **health** | Service health checks | GET /api/health |
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Request Flow (HTTP)
|
||||
```
|
||||
Client Request
|
||||
↓
|
||||
Express Router (routes/index.ts)
|
||||
↓
|
||||
Controller (parse request, validate schema)
|
||||
↓
|
||||
Service (business logic, validation)
|
||||
↓
|
||||
Repository (database query)
|
||||
↓
|
||||
Database (PostgreSQL)
|
||||
↓
|
||||
Repository (return data)
|
||||
↓
|
||||
Service (transform/orchestrate)
|
||||
↓
|
||||
Controller (format response)
|
||||
↓
|
||||
Client Response
|
||||
```
|
||||
|
||||
### Event Flow (WebSocket - Future)
|
||||
```
|
||||
Discord Gateway (publishes event)
|
||||
↓
|
||||
Redis pub/sub
|
||||
↓
|
||||
Backend WebSocket Server (ws/server.ts)
|
||||
↓
|
||||
Broadcast to connected clients
|
||||
↓
|
||||
Frontend (receives real-time update)
|
||||
```
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
### ✅ Allowed
|
||||
- Controller → Service
|
||||
- Service → Repository
|
||||
- Service → Config
|
||||
- Service → Logger
|
||||
- Repository → Database
|
||||
- Any layer → Errors, Logger, Config
|
||||
|
||||
### ❌ Forbidden
|
||||
- Repository → Service (data flows up, not down)
|
||||
- Repository → Controller
|
||||
- Service → HTTP (no req/res in service)
|
||||
- Controller → Database (must go through service)
|
||||
- Cross-module repository imports (each module owns its data)
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors inherit from `AppError` with `code` and `statusCode`:
|
||||
|
||||
```typescript
|
||||
throw new ValidationError("Invalid input", { field: "error" }); // 400
|
||||
throw new NotFoundError("Message not found"); // 404
|
||||
throw new UnauthorizedError("Invalid password"); // 401
|
||||
throw new ForbiddenError("Access denied"); // 403
|
||||
throw new AppError("Custom error", "CUSTOM_CODE", 500); // 500
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All config via environment variables (`.env`), validated with Zod in `shared/config/index.ts`:
|
||||
|
||||
```env
|
||||
# Server
|
||||
WEBSERVER_PORT=3001
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost:5432/discord_moderation
|
||||
# OR
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=discord_moderation
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=secret
|
||||
|
||||
# Redis (optional, for pub/sub)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Discord
|
||||
MONITOR_GUILD_ID=123456789
|
||||
|
||||
# Admin
|
||||
ADMIN_PASSWORD=secret123
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each module should have tests:
|
||||
- `messages.repository.test.ts` — Database query tests
|
||||
- `messages.service.test.ts` — Business logic tests
|
||||
- `messages.controller.test.ts` — HTTP handler tests
|
||||
|
||||
Use Vitest with mocked database and services.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Migrate Drizzle schema** from `src/database/schema.ts` to `services/backend/src/shared/database/schema.ts`
|
||||
2. **Implement repository queries** for each module using Drizzle ORM
|
||||
3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener
|
||||
4. **Create Discord Gateway service** in `services/discord-gateway/` (separate microservice)
|
||||
5. **Add Docker & CI/CD** for multi-service deployment
|
||||
6. **Write integration tests** for full request flow
|
||||
|
||||
## Circular Dependency Check
|
||||
|
||||
✅ No circular dependencies detected:
|
||||
- Modules are independent (each owns its data)
|
||||
- Layers flow upward only (Repository → Service → Controller)
|
||||
- Shared infrastructure has no dependencies on modules
|
||||
- Cross-module communication via events (Redis pub/sub), not direct imports
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "discord-moderation-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend service for Discord moderation monitoring",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"format": "biome format --write .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"axios": "^1.16.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"ws": "^8.20.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import express, {
|
||||
type Express,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import helmet from "helmet";
|
||||
import { createAnalyticsRouter } from "../modules/analytics/routes/index.js";
|
||||
import { createHealthRouter } from "../modules/health/routes/index.js";
|
||||
import { createMediaRouter } from "../modules/media/routes/index.js";
|
||||
import { createMessagesRouter } from "../modules/messages/routes/index.js";
|
||||
import { createVoiceRouter } from "../modules/voice/routes/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { errorHandler } from "../shared/middlewares/index.js";
|
||||
|
||||
const logger = createChildLogger("http.app");
|
||||
|
||||
export function createHttpApp(): Express {
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith("/api/")) {
|
||||
res.set("Cache-Control", "no-store");
|
||||
}
|
||||
res.on("finish", () => {
|
||||
if (req.originalUrl.startsWith("/.well-known/")) return;
|
||||
if (req.originalUrl === "/favicon.ico") return;
|
||||
if (res.statusCode >= 400) {
|
||||
logger.warn(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
},
|
||||
"HTTP request failed",
|
||||
);
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check (no auth required)
|
||||
app.use("/api", createHealthRouter());
|
||||
|
||||
// API routes
|
||||
app.use("/api", createMessagesRouter());
|
||||
app.use("/api", createAnalyticsRouter());
|
||||
app.use("/api", createMediaRouter());
|
||||
app.use("/api", createVoiceRouter());
|
||||
|
||||
// 404 handler
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
error: "NOT_FOUND",
|
||||
message: "Endpoint not found",
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { initializeDatabase } from "../shared/database/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { createHttpApp } from "./app.js";
|
||||
|
||||
const logger = createChildLogger("http.server");
|
||||
|
||||
export async function startHttpServer() {
|
||||
await initializeDatabase();
|
||||
|
||||
const app = createHttpApp();
|
||||
const port = config.WEBSERVER_PORT;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const server = app.listen(port, () => {
|
||||
logger.info({ port }, "HTTP server started");
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
logger.error({ err }, "HTTP server error");
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { startHttpServer } from "./http/server.js";
|
||||
import { createChildLogger } from "./shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("backend");
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
logger.info("Starting Discord Moderation Backend Service");
|
||||
await startHttpServer();
|
||||
logger.info("Backend service ready");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start backend service");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Received SIGINT, shutting down gracefully");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("Received SIGTERM, shutting down gracefully");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.error({ err }, "Uncaught exception");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error({ reason }, "Unhandled rejection");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { analyticsQuerySchema } from "./analytics.schema.js";
|
||||
import { analyticsService } from "./analytics.service.js";
|
||||
|
||||
const logger = createChildLogger("analytics.controller");
|
||||
|
||||
function requireQueryString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing query parameter: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleGetOverview(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get overview");
|
||||
const result = await analyticsService.getOverview(query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetDailyTrend(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
const hours = req.query.hours ? Number(req.query.hours) : 24;
|
||||
logger.debug({ guildId, hours }, "Handling get daily trend");
|
||||
const result = await analyticsService.getDailyTrend(guildId, hours);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetHourlyStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
const hours = req.query.hours ? Number(req.query.hours) : 24;
|
||||
logger.debug({ guildId, hours }, "Handling get hourly stats");
|
||||
const result = await analyticsService.getHourlyStats(guildId, hours);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetTopViolators(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||
logger.debug({ guildId, limit }, "Handling get top violators");
|
||||
const result = await analyticsService.getTopViolators(guildId, limit);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetUserLeaderboard(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||
logger.debug({ guildId, limit }, "Handling get user leaderboard");
|
||||
const result = await analyticsService.getUserLeaderboard(guildId, limit);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetModerationStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireQueryString(req.query.guildId, "guildId");
|
||||
logger.debug({ guildId }, "Handling get moderation stats");
|
||||
const result = await analyticsService.getModerationStats(guildId);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("analytics.repository");
|
||||
|
||||
export class AnalyticsRepository {
|
||||
async getOverview(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting analytics overview");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return {
|
||||
totalMessages: 0,
|
||||
totalUsers: 0,
|
||||
flaggedMessages: 0,
|
||||
averageSeverity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getDailyTrend(guildId: string, hours = 24) {
|
||||
logger.debug({ guildId, hours }, "Getting daily trend");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return [];
|
||||
}
|
||||
|
||||
async getHourlyStats(guildId: string, hours = 24) {
|
||||
logger.debug({ guildId, hours }, "Getting hourly stats");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return [];
|
||||
}
|
||||
|
||||
async getTopViolators(guildId: string, limit = 10) {
|
||||
logger.debug({ guildId, limit }, "Getting top violators");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return [];
|
||||
}
|
||||
|
||||
async getUserLeaderboard(guildId: string, limit = 10) {
|
||||
logger.debug({ guildId, limit }, "Getting user leaderboard");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModerationStats(guildId: string) {
|
||||
logger.debug({ guildId }, "Getting moderation stats");
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return {
|
||||
clean: 0,
|
||||
warn: 0,
|
||||
flagged: 0,
|
||||
error: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsRepository = new AnalyticsRepository();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const analyticsQuerySchema = z.object({
|
||||
guildId: z.string(),
|
||||
channelId: z.string().optional(),
|
||||
hours: z.coerce.number().int().positive().default(24),
|
||||
});
|
||||
|
||||
export type AnalyticsQuery = z.infer<typeof analyticsQuerySchema>;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { ForbiddenError, ValidationError } from "../../shared/errors/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { analyticsRepository } from "./analytics.repository.js";
|
||||
import type { AnalyticsQuery } from "./analytics.schema.js";
|
||||
|
||||
const logger = createChildLogger("analytics.service");
|
||||
|
||||
export class AnalyticsService {
|
||||
private assertMonitorGuild(guildId: string) {
|
||||
if (!config.MONITOR_GUILD_ID) {
|
||||
throw new ValidationError("MONITOR_GUILD_ID is not configured");
|
||||
}
|
||||
|
||||
if (guildId !== config.MONITOR_GUILD_ID) {
|
||||
throw new ForbiddenError("Analytics are restricted to the monitor guild");
|
||||
}
|
||||
}
|
||||
|
||||
async getOverview(query: AnalyticsQuery) {
|
||||
this.assertMonitorGuild(query.guildId);
|
||||
logger.debug({ query }, "Getting analytics overview");
|
||||
return analyticsRepository.getOverview(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
}
|
||||
|
||||
async getDailyTrend(guildId: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, hours }, "Getting daily trend");
|
||||
return analyticsRepository.getDailyTrend(guildId, hours);
|
||||
}
|
||||
|
||||
async getHourlyStats(guildId: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, hours }, "Getting hourly stats");
|
||||
return analyticsRepository.getHourlyStats(guildId, hours);
|
||||
}
|
||||
|
||||
async getTopViolators(guildId: string, limit = 10) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, limit }, "Getting top violators");
|
||||
return analyticsRepository.getTopViolators(guildId, limit);
|
||||
}
|
||||
|
||||
async getUserLeaderboard(guildId: string, limit = 10) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, limit }, "Getting user leaderboard");
|
||||
return analyticsRepository.getUserLeaderboard(guildId, limit);
|
||||
}
|
||||
|
||||
async getModerationStats(guildId: string) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId }, "Getting moderation stats");
|
||||
return analyticsRepository.getModerationStats(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = new AnalyticsService();
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import {
|
||||
handleGetDailyTrend,
|
||||
handleGetHourlyStats,
|
||||
handleGetModerationStats,
|
||||
handleGetOverview,
|
||||
handleGetTopViolators,
|
||||
handleGetUserLeaderboard,
|
||||
} from "../analytics.controller.js";
|
||||
|
||||
export function createAnalyticsRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/analytics/overview", handleGetOverview);
|
||||
router.get("/analytics/daily-trend", handleGetDailyTrend);
|
||||
router.get("/analytics/hourly-stats", handleGetHourlyStats);
|
||||
router.get("/analytics/top-violators", handleGetTopViolators);
|
||||
router.get("/analytics/user-leaderboard", handleGetUserLeaderboard);
|
||||
router.get("/analytics/moderation-stats", handleGetModerationStats);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { healthService } from "./health.service.js";
|
||||
|
||||
export function handleHealthCheck(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const verbose = req.query.verbose === "true";
|
||||
const result = await healthService.getHealth(verbose);
|
||||
const status = result.status === "healthy" ? 200 : 503;
|
||||
res.status(status).json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("health.repository");
|
||||
|
||||
export class HealthRepository {
|
||||
async checkDatabaseConnection() {
|
||||
try {
|
||||
// TODO: Implement actual health check
|
||||
return { connected: true };
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Database health check failed");
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const healthRepository = new HealthRepository();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const healthCheckSchema = z.object({
|
||||
verbose: z.coerce.boolean().optional().default(false),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { healthRepository } from "./health.repository.js";
|
||||
|
||||
const logger = createChildLogger("health.service");
|
||||
|
||||
export class HealthService {
|
||||
async getHealth(verbose = false) {
|
||||
const dbStatus = await healthRepository.checkDatabaseConnection();
|
||||
|
||||
return {
|
||||
status: dbStatus.connected ? "healthy" : "degraded",
|
||||
timestamp: Date.now(),
|
||||
...(verbose && {
|
||||
database: dbStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const healthService = new HealthService();
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { handleHealthCheck } from "../health.controller.js";
|
||||
|
||||
export function createHealthRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/health
|
||||
router.get("/health", handleHealthCheck);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("media.service");
|
||||
|
||||
export class MediaService {
|
||||
// TODO: Implement media service methods
|
||||
}
|
||||
|
||||
export const mediaService = new MediaService();
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
|
||||
export function createMediaRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement media routes
|
||||
// GET /api/media/list
|
||||
// POST /api/media/upload
|
||||
// GET /api/media/:id
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { messageQuerySchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
const logger = createChildLogger("messages.controller");
|
||||
|
||||
function requireRouteParam(
|
||||
value: string | string[] | undefined,
|
||||
name: string,
|
||||
): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing route parameter: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleListMessages(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling list messages request");
|
||||
const result = await messagesService.listMessages(query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetMessagesByChannel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireRouteParam(req.params.channelId, "channelId");
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetMessageById(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const id = requireRouteParam(req.params.id, "id");
|
||||
logger.debug({ id }, "Handling get message by ID");
|
||||
const result = await messagesService.getMessageById(id);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetAttachmentsByChannel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireRouteParam(req.params.channelId, "channelId");
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
||||
const result = await messagesService.getAttachmentsByChannel(
|
||||
channelId,
|
||||
query,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import type {
|
||||
MessageCreate,
|
||||
MessageQuery,
|
||||
MessageUpdate,
|
||||
} from "./messages.schema.js";
|
||||
|
||||
const logger = createChildLogger("messages.repository");
|
||||
|
||||
export class MessagesRepository {
|
||||
async findMany(query: MessageQuery) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ query }, "Finding messages");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
// This is a placeholder that will be filled in when schema is migrated
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ id }, "Finding message by ID");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM query
|
||||
return null;
|
||||
}
|
||||
|
||||
async findByChannel(channelId: string, query: MessageQuery) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ channelId, query }, "Finding messages by channel");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: MessageCreate) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ data }, "Creating message");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM insert
|
||||
return {
|
||||
id: "msg_" + Date.now(),
|
||||
...data,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, data: MessageUpdate) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ id, data }, "Updating message");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM update
|
||||
return null;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ id }, "Deleting message");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM delete
|
||||
return true;
|
||||
}
|
||||
|
||||
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
|
||||
const db = getDatabase();
|
||||
logger.debug({ channelId, query }, "Getting attachments by channel");
|
||||
|
||||
// TODO: Implement actual Drizzle ORM queries
|
||||
return {
|
||||
attachments: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesRepository = new MessagesRepository();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const messageQuerySchema = z.object({
|
||||
channelId: z.string().optional(),
|
||||
guildId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
status: z.enum(["pending", "clean", "warn", "flagged", "error"]).optional(),
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const messageCreateSchema = z.object({
|
||||
guildId: z.string(),
|
||||
channelId: z.string(),
|
||||
threadId: z.string().optional(),
|
||||
userId: z.string(),
|
||||
username: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
content: z.string(),
|
||||
type: z.enum(["text", "edited", "deleted"]).default("text"),
|
||||
});
|
||||
|
||||
export const messageUpdateSchema = z.object({
|
||||
editedContent: z.string().optional(),
|
||||
aiStatus: z.enum(["pending", "clean", "warn", "flagged", "error"]).optional(),
|
||||
aiAnalysis: z.string().optional(),
|
||||
aiCategories: z.string().optional(),
|
||||
aiSeverity: z.enum(["none", "low", "medium", "high", "critical"]).optional(),
|
||||
aiConfidence: z.number().optional(),
|
||||
});
|
||||
|
||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NotFoundError, ValidationError } from "../../shared/errors/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { messagesRepository } from "./messages.repository.js";
|
||||
import type { MessageQuery } from "./messages.schema.js";
|
||||
|
||||
const logger = createChildLogger("messages.service");
|
||||
|
||||
export class MessagesService {
|
||||
async listMessages(query: MessageQuery) {
|
||||
if (!query.channelId && !query.guildId) {
|
||||
throw new ValidationError("Either channelId or guildId is required");
|
||||
}
|
||||
|
||||
logger.debug({ query }, "Listing messages");
|
||||
return messagesRepository.findMany(query);
|
||||
}
|
||||
|
||||
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||
if (!channelId) {
|
||||
throw new ValidationError("channelId is required");
|
||||
}
|
||||
|
||||
logger.debug({ channelId, query }, "Getting messages by channel");
|
||||
return messagesRepository.findByChannel(channelId, query);
|
||||
}
|
||||
|
||||
async getMessageById(id: string) {
|
||||
if (!id) {
|
||||
throw new ValidationError("message ID is required");
|
||||
}
|
||||
|
||||
const message = await messagesRepository.findById(id);
|
||||
if (!message) {
|
||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
|
||||
if (!channelId) {
|
||||
throw new ValidationError("channelId is required");
|
||||
}
|
||||
|
||||
logger.debug({ channelId, query }, "Getting attachments by channel");
|
||||
return messagesRepository.getAttachmentsByChannel(channelId, query);
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesService = new MessagesService();
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import {
|
||||
handleGetAttachmentsByChannel,
|
||||
handleGetMessageById,
|
||||
handleGetMessagesByChannel,
|
||||
handleListMessages,
|
||||
} from "../messages.controller.js";
|
||||
|
||||
export function createMessagesRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/messages - List messages
|
||||
router.get("/messages", handleListMessages);
|
||||
|
||||
// GET /api/messages/:channelId - Get messages by channel
|
||||
router.get("/messages/:channelId", handleGetMessagesByChannel);
|
||||
|
||||
// GET /api/messages/:channelId/attachments - Get attachments by channel
|
||||
router.get("/messages/:channelId/attachments", handleGetAttachmentsByChannel);
|
||||
|
||||
// GET /api/messages/:id - Get single message by ID
|
||||
router.get("/messages/:id", handleGetMessageById);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
|
||||
export function createVoiceRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement voice routes
|
||||
// GET /api/voice/recordings
|
||||
// GET /api/voice/recordings/:userId
|
||||
// POST /api/voice/connect
|
||||
// POST /api/voice/disconnect
|
||||
|
||||
return router;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user