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:
MythEclipse
2026-06-01 21:44:29 +07:00
co-authored by Claude Opus 4.8
parent bda8304bb9
commit c48a0c5e3b
193 changed files with 16879 additions and 1158 deletions
+66 -26
View File
@@ -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
View File
@@ -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"]
+380
View File
@@ -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
View File
@@ -3,6 +3,9 @@
"includes": [
"src/**/*.ts",
"tests/**/*.ts",
"services/**/*.ts",
"services/**/*.tsx",
"packages/**/*.ts",
"*.json",
"*.ts",
"!vendor/**",
-60
View File
@@ -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();
-28
View File
@@ -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
-137
View File
@@ -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");
}
-56
View File
@@ -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>
);
}
-149
View File
@@ -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 };
}
-13
View File
@@ -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 };
}
-26
View File
@@ -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} />;
}
+29
View File
@@ -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"]
+29
View File
@@ -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"]
+29
View File
@@ -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"]
+67
View File
@@ -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
+27
View File
@@ -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"
}
}
+86
View File
@@ -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";
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./errors/index.js";
export * from "./logger/index.js";
export * from "./types/index.js";
export * from "./utils/index.js";
+25
View File
@@ -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);
}
+70
View File
@@ -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;
}
+61
View File
@@ -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;
}
+19
View File
@@ -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"]
}
+300 -6
View File
@@ -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: {}
+4
View File
@@ -1,5 +1,9 @@
packages:
- .
- services/frontend
- services/backend
- services/discord-gateway
- packages/shared
- vendor/discord-video-stream
- vendor/discord.js-selfbot-v13
+275
View File
@@ -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
+41
View File
@@ -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"
}
}
+75
View File
@@ -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;
}
+25
View File
@@ -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);
});
});
}
+38
View File
@@ -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