Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/generated/
|
||||
**/.env
|
||||
**/*.db
|
||||
.git/
|
||||
.claude/
|
||||
.vscode/
|
||||
@@ -0,0 +1,12 @@
|
||||
# Backend
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
DATABASE_URL="file:./data/ts6webui.db"
|
||||
JWT_SECRET=change-this-to-a-random-string-at-least-32-chars
|
||||
JWT_ACCESS_EXPIRY=15m
|
||||
JWT_REFRESH_EXPIRY=7d
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Frontend (Vite env vars)
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_WS_URL=ws://localhost:3001/ws
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
packages/backend/generated/
|
||||
packages/backend/data/
|
||||
packages/backend/prisma/migrations/
|
||||
.claude/
|
||||
@@ -0,0 +1,47 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
FROM node:20-slim AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Install system deps: openssl (Prisma), ffmpeg (audio), python3+pip (yt-dlp)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ca-certificates \
|
||||
&& pip3 install --break-system-packages yt-dlp \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything at once to avoid BuildKit ordering bug on Windows
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm --filter @ts6/common run build
|
||||
RUN pnpm --filter @ts6/backend exec prisma generate
|
||||
RUN pnpm --filter @ts6/backend run build
|
||||
|
||||
# Production image
|
||||
FROM base AS production
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=build /app/packages/common/dist ./packages/common/dist
|
||||
COPY --from=build /app/packages/common/package.json ./packages/common/package.json
|
||||
COPY --from=build /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=build /app/packages/backend/generated ./packages/backend/generated
|
||||
COPY --from=build /app/packages/backend/prisma ./packages/backend/prisma
|
||||
COPY --from=build /app/packages/backend/package.json ./packages/backend/package.json
|
||||
COPY --from=build /app/packages/backend/node_modules ./packages/backend/node_modules
|
||||
|
||||
WORKDIR /app/packages/backend
|
||||
RUN mkdir -p data /data/music
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["sh", "-c", "npx prisma db push --skip-generate && npx prisma db seed || true && node dist/index.js"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
FROM node:20-slim AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything at once to avoid BuildKit ordering bug on Windows
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm --filter @ts6/common run build
|
||||
RUN pnpm --filter @ts6/frontend run build
|
||||
|
||||
# Production: serve with nginx
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
COPY --from=build /app/packages/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Nginx config for SPA routing + API proxy
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
server_name _;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
client_max_body_size 150m;\n\
|
||||
\n\
|
||||
location /api {\n\
|
||||
proxy_pass http://backend:3001;\n\
|
||||
proxy_http_version 1.1;\n\
|
||||
proxy_set_header Upgrade $http_upgrade;\n\
|
||||
proxy_set_header Connection "upgrade";\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
proxy_set_header X-Real-IP $remote_addr;\n\
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
|
||||
proxy_set_header X-Forwarded-Proto $scheme;\n\
|
||||
}\n\
|
||||
\n\
|
||||
location /ws {\n\
|
||||
proxy_pass http://backend:3001;\n\
|
||||
proxy_http_version 1.1;\n\
|
||||
proxy_set_header Upgrade $http_upgrade;\n\
|
||||
proxy_set_header Connection "upgrade";\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
}\n\
|
||||
\n\
|
||||
location / {\n\
|
||||
try_files $uri $uri/ /index.html;\n\
|
||||
}\n\
|
||||
\n\
|
||||
gzip on;\n\
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,182 @@
|
||||
# TS6 Manager
|
||||
|
||||
Web-based management interface for TeamSpeak servers. Control virtual servers, channels, clients, permissions, music bots, and automated bot flows — all from your browser.
|
||||
|
||||
Built on the **WebQuery HTTP API** (the ServerQuery replacement in modern TeamSpeak builds). Telnet is not used or supported.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
**Server Management**
|
||||
- Dashboard with live server stats (uptime, slots, traffic)
|
||||
- Virtual server list with start/stop controls
|
||||
- Channel tree with drag-and-drop ordering
|
||||
- Client list with kick, ban, move, poke actions
|
||||
- Server & channel group management
|
||||
- Permission editor (server, channel, client, group-level)
|
||||
- Ban list, token/privilege key management
|
||||
- Complaint viewer and offline message system
|
||||
- Server log viewer with filtering
|
||||
- File browser for channel files
|
||||
- Instance-level settings
|
||||
|
||||
**Music Bots**
|
||||
- Multiple bots per server, each with independent queue
|
||||
- Radio station streaming (ICY metadata, live title updates)
|
||||
- YouTube playback via yt-dlp (download + queue)
|
||||
- Music library management (upload, organize, playlists)
|
||||
- Volume control, pause, skip, previous, shuffle, repeat
|
||||
- Auto-reconnect with exponential backoff
|
||||
- In-channel text commands: `!radio`, `!play`, `!stop`, `!pause`, `!skip`, `!vol`, `!np`
|
||||
|
||||
**Bot Flow Engine**
|
||||
- Visual flow editor (drag-and-drop, node-based)
|
||||
- Triggers: TS3 events, cron schedules, webhooks, chat commands
|
||||
- Actions: kick, ban, move, message, poke, channel create/edit/delete
|
||||
- Conditions, variables, delays, loops, logging
|
||||
- Animated channel names (rotating text on a timer)
|
||||
- Placeholder system with filters and expressions
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Frontend │────▶│ Backend │────▶│ TS Server │
|
||||
│ React SPA │ │ Express API │ │ WebQuery HTTP │
|
||||
│ nginx :80 │ │ Node :3001 │ │ SSH (events) │
|
||||
└──────────────┘ └──────┬───────┘ └─────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ SQLite │
|
||||
│ (Prisma) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Three packages** in a pnpm monorepo:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@ts6/common` | Shared types, constants, utilities |
|
||||
| `@ts6/backend` | Express API, WebQuery client, bot engine, voice bots |
|
||||
| `@ts6/frontend` | React SPA with Vite, TailwindCSS, shadcn/ui |
|
||||
|
||||
The backend proxies all TeamSpeak API calls. The frontend never has direct access to API keys or server credentials.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Frontend:** React 18, Vite, TailwindCSS, shadcn/ui, TanStack Query + Table, React Flow, Recharts, Zustand
|
||||
|
||||
**Backend:** Node.js, Express, Prisma (SQLite), JWT authentication, WebQuery HTTP client, SSH event listener
|
||||
|
||||
**Voice/Audio:** Custom TS3 voice protocol client (UDP), Opus encoding via FFmpeg, yt-dlp for YouTube
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/clusterzx/ts6-manager.git
|
||||
cd ts6-manager
|
||||
|
||||
# Set a real secret before deploying
|
||||
export JWT_SECRET=$(openssl rand -base64 32)
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Frontend: `http://localhost:3000`
|
||||
Backend API: `http://localhost:3001`
|
||||
|
||||
On first launch, register an admin account through the UI, then add your TeamSpeak server connection (host, WebQuery port, API key).
|
||||
|
||||
### Pre-built Images
|
||||
|
||||
```bash
|
||||
docker pull clusterzx/ts6-manager:frontend
|
||||
docker pull clusterzx/ts6-manager:backend
|
||||
```
|
||||
|
||||
### Coolify / Reverse Proxy
|
||||
|
||||
When deploying behind a reverse proxy (Coolify, Traefik, etc.), remove the `ports` section and let the proxy handle routing. If your TS server runs in a separate Docker network, add it as an external network to the backend service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
networks:
|
||||
- ts6-network
|
||||
- ts-server-net
|
||||
|
||||
networks:
|
||||
ts-server-net:
|
||||
external: true
|
||||
name: your-ts-server-network-id
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Requires: Node.js 20+, pnpm 9+
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # starts backend + frontend in parallel
|
||||
```
|
||||
|
||||
Backend runs on `:3001`, frontend on `:5173` (Vite dev server).
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
pnpm build # builds all packages
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
Prisma with SQLite. On first run:
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
The Docker images handle this automatically.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3001` | Backend port |
|
||||
| `DATABASE_URL` | `file:./data/ts6webui.db` | SQLite database path |
|
||||
| `JWT_SECRET` | — | **Required.** Secret for JWT signing |
|
||||
| `JWT_ACCESS_EXPIRY` | `15m` | Access token lifetime |
|
||||
| `JWT_REFRESH_EXPIRY` | `7d` | Refresh token lifetime |
|
||||
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
|
||||
| `MUSIC_DIR` | `/data/music` | Directory for downloaded music files |
|
||||
|
||||
## Music Bot Text Commands
|
||||
|
||||
When a music bot is running and connected to a channel, users in that channel can control it via chat:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!radio` | List available radio stations |
|
||||
| `!radio <id>` | Play a radio station |
|
||||
| `!play <url>` | Play from YouTube URL |
|
||||
| `!play` | Resume paused playback |
|
||||
| `!stop` | Stop playback |
|
||||
| `!pause` | Toggle pause/resume |
|
||||
| `!skip` / `!next` | Next track in queue |
|
||||
| `!prev` | Previous track |
|
||||
| `!vol` | Show current volume |
|
||||
| `!vol <0-100>` | Set volume |
|
||||
| `!np` | Show current track |
|
||||
|
||||
## Requirements
|
||||
|
||||
- TeamSpeak server with **WebQuery HTTP** enabled (not raw/telnet)
|
||||
- WebQuery API key (generated via `apikeyadd` or server admin tools)
|
||||
- SSH access to the TS server (only needed for bot event triggers)
|
||||
- `yt-dlp` and `ffmpeg` installed (included in Docker image, needed for music bots)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,42 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: clusterzx/ts6-manager:backend
|
||||
container_name: ts6-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-to-a-random-secret-at-least-32-characters-long}
|
||||
- JWT_ACCESS_EXPIRY=15m
|
||||
- JWT_REFRESH_EXPIRY=7d
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://ts6.example.com}
|
||||
- MUSIC_DIR=/data/music
|
||||
volumes:
|
||||
- backend-data:/app/packages/backend/data
|
||||
- music-data:/data/music
|
||||
networks:
|
||||
- ts6-network
|
||||
- coolify-ts
|
||||
|
||||
frontend:
|
||||
image: clusterzx/ts6-manager:frontend
|
||||
container_name: ts6-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
music-data:
|
||||
|
||||
networks:
|
||||
ts6-network:
|
||||
driver: bridge
|
||||
coolify-ts:
|
||||
external: true
|
||||
name: h4c0kkoccks8sw8woow88o84
|
||||
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: ts6-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-to-a-random-secret-at-least-32-characters-long}
|
||||
- JWT_ACCESS_EXPIRY=15m
|
||||
- JWT_REFRESH_EXPIRY=7d
|
||||
- FRONTEND_URL=http://localhost:3000
|
||||
- MUSIC_DIR=/data/music
|
||||
volumes:
|
||||
- backend-data:/app/packages/backend/data
|
||||
- music-data:/data/music
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: ts6-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- ts6-network
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
driver: local
|
||||
music-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
ts6-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "teamspeak-6-webui",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel -r run dev",
|
||||
"dev:backend": "pnpm --filter @ts6/backend run dev",
|
||||
"dev:frontend": "pnpm --filter @ts6/frontend run dev",
|
||||
"build": "pnpm -r run build",
|
||||
"db:generate": "pnpm --filter @ts6/backend exec prisma generate",
|
||||
"db:migrate": "pnpm --filter @ts6/backend exec prisma migrate dev",
|
||||
"db:seed": "pnpm --filter @ts6/backend exec prisma db seed",
|
||||
"lint": "pnpm -r run lint",
|
||||
"typecheck": "pnpm -r run typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@ts6/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ts6/common": "workspace:*",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"express": "^4.21.0",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^8.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"ws": "^8.18.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"zod": "^3.23.0",
|
||||
"pino": "^9.4.0",
|
||||
"pino-pretty": "^11.2.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"expr-eval": "^2.0.2",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"opusscript": "^0.1.1",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/ssh2": "^1.15.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^6.3.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
displayName String
|
||||
role String @default("viewer")
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
|
||||
serverAccess UserServerAccess[]
|
||||
refreshTokens RefreshToken[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id Int @id @default(autoincrement())
|
||||
token String @unique
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model UserServerAccess {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
serverConfigId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, serverConfigId])
|
||||
}
|
||||
|
||||
model TsServerConfig {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
host String
|
||||
webqueryPort Int @default(10080)
|
||||
apiKey String
|
||||
useHttps Boolean @default(false)
|
||||
sshPort Int @default(10022)
|
||||
sshUsername String?
|
||||
sshPassword String?
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userAccess UserServerAccess[]
|
||||
botFlows BotFlow[]
|
||||
botLogs BotExecutionLog[]
|
||||
musicBots MusicBot[]
|
||||
songs Song[]
|
||||
radioStations RadioStation[]
|
||||
}
|
||||
|
||||
model BotFlow {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
flowData String @default("{\"nodes\":[],\"edges\":[]}")
|
||||
serverConfigId Int
|
||||
virtualServerId Int @default(1)
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
|
||||
enabled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
executions BotExecution[]
|
||||
variables BotVariable[]
|
||||
}
|
||||
|
||||
model BotVariable {
|
||||
id Int @id @default(autoincrement())
|
||||
flowId Int
|
||||
flow BotFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
value String @default("")
|
||||
scope String @default("flow")
|
||||
|
||||
@@unique([flowId, name, scope])
|
||||
}
|
||||
|
||||
model BotExecution {
|
||||
id Int @id @default(autoincrement())
|
||||
flowId Int
|
||||
flow BotFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
|
||||
triggeredBy String
|
||||
triggerData String?
|
||||
status String @default("running")
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
error String?
|
||||
|
||||
logs BotExecutionLog[]
|
||||
}
|
||||
|
||||
model BotExecutionLog {
|
||||
id Int @id @default(autoincrement())
|
||||
executionId Int?
|
||||
execution BotExecution? @relation(fields: [executionId], references: [id], onDelete: Cascade)
|
||||
serverConfigId Int
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id])
|
||||
flowId Int?
|
||||
nodeId String?
|
||||
nodeName String?
|
||||
level String @default("info")
|
||||
message String
|
||||
data String?
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
@@index([executionId])
|
||||
@@index([flowId])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// === Music / Voice Bot Models ===
|
||||
|
||||
model MusicBot {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
serverConfigId Int
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
|
||||
nickname String @default("MusicBot")
|
||||
defaultChannel String?
|
||||
channelPassword String?
|
||||
volume Int @default(50)
|
||||
identityData String?
|
||||
autoStart Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
playlists Playlist[]
|
||||
}
|
||||
|
||||
model Song {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
artist String?
|
||||
duration Float?
|
||||
filePath String
|
||||
source String @default("local")
|
||||
sourceUrl String?
|
||||
fileSize Int?
|
||||
serverConfigId Int
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
playlistSongs PlaylistSong[]
|
||||
}
|
||||
|
||||
model Playlist {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
musicBotId Int?
|
||||
musicBot MusicBot? @relation(fields: [musicBotId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
songs PlaylistSong[]
|
||||
}
|
||||
|
||||
model PlaylistSong {
|
||||
id Int @id @default(autoincrement())
|
||||
playlistId Int
|
||||
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||
songId Int
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
position Int @default(0)
|
||||
|
||||
@@unique([playlistId, songId])
|
||||
}
|
||||
|
||||
model RadioStation {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
genre String?
|
||||
imageUrl String?
|
||||
serverConfigId Int
|
||||
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaClient } from '../generated/prisma/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin', 12);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'admin',
|
||||
passwordHash,
|
||||
displayName: 'Administrator',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Seed default app settings
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key: 'max_music_bots' },
|
||||
update: {},
|
||||
create: { key: 'max_music_bots', value: '5' },
|
||||
});
|
||||
|
||||
console.log('Seed completed: default admin user + app settings created');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,80 @@
|
||||
import express, { type Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { config } from './config.js';
|
||||
import { errorHandler } from './middleware/error-handler.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { serverRoutes } from './routes/servers.routes.js';
|
||||
import { virtualServerRoutes } from './routes/virtual-servers.routes.js';
|
||||
import { channelRoutes } from './routes/channels.routes.js';
|
||||
import { clientRoutes } from './routes/clients.routes.js';
|
||||
import { serverGroupRoutes } from './routes/server-groups.routes.js';
|
||||
import { channelGroupRoutes } from './routes/channel-groups.routes.js';
|
||||
import { permissionRoutes } from './routes/permissions.routes.js';
|
||||
import { banRoutes } from './routes/bans.routes.js';
|
||||
import { tokenRoutes } from './routes/tokens.routes.js';
|
||||
import { fileRoutes } from './routes/files.routes.js';
|
||||
import { complaintRoutes } from './routes/complaints.routes.js';
|
||||
import { messageRoutes } from './routes/messages.routes.js';
|
||||
import { logRoutes } from './routes/logs.routes.js';
|
||||
import { instanceRoutes } from './routes/instance.routes.js';
|
||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||
import { botRoutes } from './routes/bots.routes.js';
|
||||
import { userRoutes } from './routes/users.routes.js';
|
||||
import { musicBotRoutes } from './routes/music-bots.routes.js';
|
||||
import { musicLibraryRoutes } from './routes/music-library.routes.js';
|
||||
import { playlistRoutes } from './routes/playlists.routes.js';
|
||||
import { radioStationRoutes } from './routes/radio-stations.routes.js';
|
||||
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: config.frontendUrl, credentials: true }));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Public routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Bot webhook route (unauthenticated — called by external systems)
|
||||
app.all('/api/bots/webhook/:path(*)', (req, res) => {
|
||||
const engine = req.app.locals.botEngine;
|
||||
if (!engine) return res.status(503).json({ error: 'Bot engine not running' });
|
||||
engine.handleWebhookRequest(req, res);
|
||||
});
|
||||
|
||||
// Protected routes
|
||||
app.use('/api', authMiddleware);
|
||||
app.use('/api/servers', serverRoutes);
|
||||
app.use('/api/servers/:configId/virtual-servers', virtualServerRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/channels', channelRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/clients', clientRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/server-groups', serverGroupRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/channel-groups', channelGroupRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/permissions', permissionRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/bans', banRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/tokens', tokenRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/files', fileRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/complaints', complaintRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/messages', messageRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/logs', logRoutes);
|
||||
app.use('/api/servers/:configId/instance', instanceRoutes);
|
||||
app.use('/api/servers/:configId/vs/:sid/dashboard', dashboardRoutes);
|
||||
app.use('/api/bots', botRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/music-bots', musicBotRoutes);
|
||||
app.use('/api/servers/:configId/music-library', musicLibraryRoutes);
|
||||
app.use('/api/playlists', playlistRoutes);
|
||||
app.use('/api/servers/:configId/radio-stations', radioStationRoutes);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { WebQueryClient } from '../ts-client/webquery-client.js';
|
||||
|
||||
export type AnimationStyle = 'scroll' | 'typewriter' | 'bounce' | 'blink' | 'wave' | 'alternateCase';
|
||||
|
||||
export interface AnimationConfig {
|
||||
channelId: string;
|
||||
text: string;
|
||||
style: AnimationStyle;
|
||||
intervalSeconds: number;
|
||||
prefix: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
interface ActiveAnimation {
|
||||
timer: ReturnType<typeof setInterval>;
|
||||
frameIndex: number;
|
||||
}
|
||||
|
||||
const MAX_CHANNEL_NAME = 40;
|
||||
|
||||
// --- Frame generators (pure functions, return string[]) ---
|
||||
|
||||
function generateScrollFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
if (text.length <= maxLen) {
|
||||
// Pad and scroll
|
||||
const padded = text + ' ';
|
||||
const frames: string[] = [];
|
||||
for (let i = 0; i < padded.length; i++) {
|
||||
const shifted = padded.slice(i) + padded.slice(0, i);
|
||||
frames.push(prefix + shifted.slice(0, maxLen));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
// Text longer than width — scroll through it
|
||||
const padded = text + ' ';
|
||||
const frames: string[] = [];
|
||||
for (let i = 0; i < padded.length; i++) {
|
||||
const shifted = padded.slice(i) + padded.slice(0, i);
|
||||
frames.push(prefix + shifted.slice(0, maxLen));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function generateTypewriterFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
const display = text.slice(0, maxLen);
|
||||
const frames: string[] = [];
|
||||
// Build up character by character
|
||||
for (let i = 1; i <= display.length; i++) {
|
||||
frames.push(prefix + display.slice(0, i));
|
||||
}
|
||||
// Hold the full text for a few frames
|
||||
frames.push(prefix + display);
|
||||
frames.push(prefix + display);
|
||||
frames.push(prefix + display);
|
||||
// Clear
|
||||
frames.push(prefix + ' ');
|
||||
frames.push(prefix + ' ');
|
||||
return frames;
|
||||
}
|
||||
|
||||
function generateBounceFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
if (text.length >= maxLen) return [prefix + text.slice(0, maxLen)];
|
||||
const space = maxLen - text.length;
|
||||
const frames: string[] = [];
|
||||
// Move right
|
||||
for (let i = 0; i <= space; i++) {
|
||||
frames.push(prefix + ' '.repeat(i) + text);
|
||||
}
|
||||
// Move left (skip endpoints to avoid double frames)
|
||||
for (let i = space - 1; i > 0; i--) {
|
||||
frames.push(prefix + ' '.repeat(i) + text);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function generateBlinkFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
const display = text.slice(0, maxLen);
|
||||
const dashes = display.replace(/[^ ]/g, '-');
|
||||
return [
|
||||
prefix + display,
|
||||
prefix + display,
|
||||
prefix + dashes,
|
||||
prefix + display,
|
||||
prefix + display,
|
||||
prefix + ' '.repeat(display.length),
|
||||
];
|
||||
}
|
||||
|
||||
function generateWaveFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
const decorPairs = [
|
||||
['» ', ' «'],
|
||||
['»» ', ' ««'],
|
||||
['»»» ', ' «««'],
|
||||
['»» ', ' ««'],
|
||||
['» ', ' «'],
|
||||
['', ''],
|
||||
];
|
||||
const frames: string[] = [];
|
||||
for (const [left, right] of decorPairs) {
|
||||
const available = maxLen - left.length - right.length;
|
||||
const display = text.slice(0, Math.max(0, available));
|
||||
frames.push(prefix + left + display + right);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function generateAlternateCaseFrames(text: string, prefix: string): string[] {
|
||||
const maxLen = MAX_CHANNEL_NAME - prefix.length;
|
||||
const display = text.slice(0, maxLen);
|
||||
const frame1 = display.split('').map((c, i) => i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()).join('');
|
||||
const frame2 = display.split('').map((c, i) => i % 2 === 0 ? c.toLowerCase() : c.toUpperCase()).join('');
|
||||
return [prefix + frame1, prefix + frame2];
|
||||
}
|
||||
|
||||
function generateFrames(text: string, style: AnimationStyle, prefix: string): string[] {
|
||||
switch (style) {
|
||||
case 'scroll': return generateScrollFrames(text, prefix);
|
||||
case 'typewriter': return generateTypewriterFrames(text, prefix);
|
||||
case 'bounce': return generateBounceFrames(text, prefix);
|
||||
case 'blink': return generateBlinkFrames(text, prefix);
|
||||
case 'wave': return generateWaveFrames(text, prefix);
|
||||
case 'alternateCase': return generateAlternateCaseFrames(text, prefix);
|
||||
default: return [prefix + text.slice(0, MAX_CHANNEL_NAME - prefix.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight {{time.*}} resolver (no API calls, just Date)
|
||||
function resolveTimeVars(text: string, timezone?: string): string {
|
||||
if (!text.includes('{{time.')) return text;
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
let hours: number, minutes: number, seconds: number;
|
||||
let day: number, month: number, year: number;
|
||||
|
||||
if (timezone && timezone !== 'UTC') {
|
||||
try {
|
||||
const fmt = (opts: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat('en-US', { ...opts, timeZone: timezone }).format(now);
|
||||
hours = Number(fmt({ hour: 'numeric', hour12: false }));
|
||||
minutes = Number(fmt({ minute: 'numeric' }));
|
||||
seconds = Number(fmt({ second: 'numeric' }));
|
||||
day = Number(fmt({ day: 'numeric' }));
|
||||
month = Number(fmt({ month: 'numeric' }));
|
||||
year = Number(fmt({ year: 'numeric' }));
|
||||
} catch {
|
||||
hours = now.getHours(); minutes = now.getMinutes(); seconds = now.getSeconds();
|
||||
day = now.getDate(); month = now.getMonth() + 1; year = now.getFullYear();
|
||||
}
|
||||
} else {
|
||||
hours = now.getHours(); minutes = now.getMinutes(); seconds = now.getSeconds();
|
||||
day = now.getDate(); month = now.getMonth() + 1; year = now.getFullYear();
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/\{\{time\.hours\}\}/g, pad(hours))
|
||||
.replace(/\{\{time\.minutes\}\}/g, pad(minutes))
|
||||
.replace(/\{\{time\.seconds\}\}/g, pad(seconds))
|
||||
.replace(/\{\{time\.time\}\}/g, `${pad(hours)}:${pad(minutes)}`)
|
||||
.replace(/\{\{time\.date\}\}/g, `${pad(day)}.${pad(month)}.${year}`)
|
||||
.replace(/\{\{time\.day\}\}/g, pad(day))
|
||||
.replace(/\{\{time\.month\}\}/g, pad(month))
|
||||
.replace(/\{\{time\.year\}\}/g, String(year))
|
||||
.replace(/\{\{time\.timestamp\}\}/g, String(Math.floor(now.getTime() / 1000)));
|
||||
}
|
||||
|
||||
export class AnimationManager {
|
||||
private animations: Map<number, ActiveAnimation> = new Map();
|
||||
|
||||
startAnimation(
|
||||
flowId: number,
|
||||
sid: number,
|
||||
config: AnimationConfig,
|
||||
client: WebQueryClient,
|
||||
): void {
|
||||
// Stop existing animation for this flow
|
||||
this.stopAnimation(flowId);
|
||||
|
||||
const intervalMs = Math.max(250, config.intervalSeconds * 1000);
|
||||
let frameIndex = 0;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
// Resolve {{time.*}} in text each tick (for dynamic content)
|
||||
const resolvedText = resolveTimeVars(config.text, config.timezone);
|
||||
const frames = generateFrames(resolvedText, config.style, config.prefix);
|
||||
if (frames.length === 0) return;
|
||||
|
||||
const channelName = frames[frameIndex % frames.length];
|
||||
frameIndex++;
|
||||
|
||||
await client.executePost(sid, 'channeledit', {
|
||||
cid: config.channelId,
|
||||
channel_name: channelName,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Log but don't stop — transient errors are common
|
||||
if (!err.message?.includes('304')) { // 304 = name unchanged, ignore
|
||||
console.error(`[AnimationManager] Flow ${flowId} channel update error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run first tick immediately
|
||||
tick();
|
||||
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
this.animations.set(flowId, { timer, frameIndex });
|
||||
|
||||
console.log(`[AnimationManager] Started animation for flow ${flowId}: style=${config.style}, interval=${config.intervalSeconds}s, channel=${config.channelId}`);
|
||||
}
|
||||
|
||||
stopAnimation(flowId: number): void {
|
||||
const anim = this.animations.get(flowId);
|
||||
if (anim) {
|
||||
clearInterval(anim.timer);
|
||||
this.animations.delete(flowId);
|
||||
console.log(`[AnimationManager] Stopped animation for flow ${flowId}`);
|
||||
}
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
for (const [flowId, anim] of this.animations) {
|
||||
clearInterval(anim.timer);
|
||||
}
|
||||
this.animations.clear();
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.animations.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Parser } from 'expr-eval';
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
|
||||
function resolveDotPath(obj: any, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current instanceof Map ? current.get(part) : current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getTimeValues(timezone?: string): Record<string, string | number> {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
if (timezone && timezone !== 'UTC') {
|
||||
try {
|
||||
const fmt = (opts: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat('en-US', { ...opts, timeZone: timezone }).format(now);
|
||||
const hours = Number(fmt({ hour: 'numeric', hour12: false }));
|
||||
const minutes = Number(fmt({ minute: 'numeric' }));
|
||||
const seconds = Number(fmt({ second: 'numeric' }));
|
||||
const day = Number(fmt({ day: 'numeric' }));
|
||||
const month = Number(fmt({ month: 'numeric' }));
|
||||
const year = Number(fmt({ year: 'numeric' }));
|
||||
const dayOfWeek = new Date(now.toLocaleString('en-US', { timeZone: timezone })).getDay();
|
||||
|
||||
return {
|
||||
hours: pad(hours),
|
||||
minutes: pad(minutes),
|
||||
seconds: pad(seconds),
|
||||
time: `${pad(hours)}:${pad(minutes)}`,
|
||||
date: `${pad(day)}.${pad(month)}.${year}`,
|
||||
timestamp: Math.floor(now.getTime() / 1000),
|
||||
day: pad(day),
|
||||
month: pad(month),
|
||||
year,
|
||||
dayOfWeek,
|
||||
};
|
||||
} catch {
|
||||
// Invalid timezone — fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hours: pad(now.getHours()),
|
||||
minutes: pad(now.getMinutes()),
|
||||
seconds: pad(now.getSeconds()),
|
||||
time: `${pad(now.getHours())}:${pad(now.getMinutes())}`,
|
||||
date: `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`,
|
||||
timestamp: Math.floor(now.getTime() / 1000),
|
||||
day: pad(now.getDate()),
|
||||
month: pad(now.getMonth() + 1),
|
||||
year: now.getFullYear(),
|
||||
dayOfWeek: now.getDay(),
|
||||
};
|
||||
}
|
||||
|
||||
export class ExecutionContext {
|
||||
private tempVars: Map<string, any> = new Map();
|
||||
private exprParser: Parser;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
public readonly flowId: number,
|
||||
public readonly executionId: number,
|
||||
public readonly configId: number,
|
||||
public readonly sid: number,
|
||||
public readonly triggerType: string,
|
||||
public readonly eventData: Record<string, string>,
|
||||
public readonly timezone?: string,
|
||||
) {
|
||||
this.exprParser = new Parser();
|
||||
// Register custom functions for expression evaluation
|
||||
this.exprParser.functions.contains = (str: string, substr: string) => String(str).includes(String(substr)) ? 1 : 0;
|
||||
this.exprParser.functions.startsWith = (str: string, prefix: string) => String(str).startsWith(String(prefix)) ? 1 : 0;
|
||||
this.exprParser.functions.endsWith = (str: string, suffix: string) => String(str).endsWith(String(suffix)) ? 1 : 0;
|
||||
this.exprParser.functions.lower = (str: string) => String(str).toLowerCase();
|
||||
this.exprParser.functions.upper = (str: string) => String(str).toUpperCase();
|
||||
this.exprParser.functions.length = (str: string) => String(str).length;
|
||||
this.exprParser.functions.split = (str: string, sep: string, index: number) => {
|
||||
const parts = String(str).split(String(sep));
|
||||
return parts[index] ?? '';
|
||||
};
|
||||
}
|
||||
|
||||
private applyFilter(value: string, filter: string): string {
|
||||
switch (filter) {
|
||||
case 'uptime': {
|
||||
const totalSec = parseInt(value, 10) || 0;
|
||||
const d = Math.floor(totalSec / 86400);
|
||||
const h = Math.floor((totalSec % 86400) / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0 || d > 0) parts.push(`${h}h`);
|
||||
parts.push(`${m}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
case 'round': return String(Math.round(parseFloat(value) || 0));
|
||||
case 'floor': return String(Math.floor(parseFloat(value) || 0));
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveTemplate(template: string): Promise<string> {
|
||||
if (!template || !template.includes('{{')) return template;
|
||||
|
||||
const matches = template.matchAll(/\{\{(.+?)\}\}/g);
|
||||
let result = template;
|
||||
|
||||
for (const match of matches) {
|
||||
const fullMatch = match[0];
|
||||
const raw = match[1].trim();
|
||||
// Support pipe filters: {{path|filter}}
|
||||
const pipeIdx = raw.indexOf('|');
|
||||
const path = pipeIdx >= 0 ? raw.substring(0, pipeIdx).trim() : raw;
|
||||
const filter = pipeIdx >= 0 ? raw.substring(pipeIdx + 1).trim() : null;
|
||||
let value = '';
|
||||
|
||||
if (path.startsWith('event.')) {
|
||||
const key = path.substring(6);
|
||||
// Support nested access: event.webhook_body.test → parse webhook_body as JSON, access .test
|
||||
if (this.eventData[key] !== undefined) {
|
||||
value = this.eventData[key];
|
||||
} else {
|
||||
const firstDot = key.indexOf('.');
|
||||
if (firstDot > 0) {
|
||||
const topKey = key.substring(0, firstDot);
|
||||
const rest = key.substring(firstDot + 1);
|
||||
const topVal = this.eventData[topKey];
|
||||
if (topVal) {
|
||||
try {
|
||||
const parsed = JSON.parse(topVal);
|
||||
const resolved = resolveDotPath(parsed, rest);
|
||||
value = resolved != null ? String(resolved) : '';
|
||||
} catch {
|
||||
value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (path.startsWith('var.')) {
|
||||
const name = path.substring(4);
|
||||
value = await this.getVariable(name);
|
||||
} else if (path.startsWith('temp.')) {
|
||||
const dotPath = path.substring(5);
|
||||
// Support nested access: temp.server.virtualserver_clientsonline
|
||||
const firstDot = dotPath.indexOf('.');
|
||||
if (firstDot === -1) {
|
||||
value = String(this.tempVars.get(dotPath) ?? '');
|
||||
} else {
|
||||
const topKey = dotPath.substring(0, firstDot);
|
||||
const rest = dotPath.substring(firstDot + 1);
|
||||
const topVal = this.tempVars.get(topKey);
|
||||
const resolved = resolveDotPath(topVal, rest);
|
||||
value = resolved != null ? String(resolved) : '';
|
||||
}
|
||||
} else if (path.startsWith('time.')) {
|
||||
const key = path.substring(5);
|
||||
const tv = getTimeValues(this.timezone);
|
||||
value = tv[key] != null ? String(tv[key]) : '';
|
||||
} else if (path.startsWith('exec.')) {
|
||||
const key = path.substring(5);
|
||||
switch (key) {
|
||||
case 'flowId': value = String(this.flowId); break;
|
||||
case 'executionId': value = String(this.executionId); break;
|
||||
case 'configId': value = String(this.configId); break;
|
||||
case 'sid': value = String(this.sid); break;
|
||||
case 'triggerType': value = this.triggerType; break;
|
||||
default: value = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (filter) value = this.applyFilter(value, filter);
|
||||
result = result.replace(fullMatch, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async evaluateCondition(expression: string): Promise<boolean> {
|
||||
try {
|
||||
const scope = await this.buildExpressionScope();
|
||||
const result = this.exprParser.evaluate(expression, scope);
|
||||
return !!result;
|
||||
} catch (err: any) {
|
||||
console.warn(`[ExecutionContext] Expression evaluation failed: "${expression}" — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getVariable(name: string): Promise<string> {
|
||||
const record = await this.prisma.botVariable.findFirst({
|
||||
where: { flowId: this.flowId, name, scope: 'flow' },
|
||||
});
|
||||
return record?.value ?? '';
|
||||
}
|
||||
|
||||
async setVariable(name: string, value: string): Promise<void> {
|
||||
const existing = await this.prisma.botVariable.findFirst({
|
||||
where: { flowId: this.flowId, name, scope: 'flow' },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.botVariable.update({
|
||||
where: { id: existing.id },
|
||||
data: { value },
|
||||
});
|
||||
} else {
|
||||
await this.prisma.botVariable.create({
|
||||
data: { flowId: this.flowId, name, value, scope: 'flow' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async incrementVariable(name: string, amount: string): Promise<void> {
|
||||
const current = await this.getVariable(name);
|
||||
const currentNum = parseFloat(current) || 0;
|
||||
const increment = parseFloat(amount) || 0;
|
||||
await this.setVariable(name, String(currentNum + increment));
|
||||
}
|
||||
|
||||
async appendVariable(name: string, value: string): Promise<void> {
|
||||
const current = await this.getVariable(name);
|
||||
await this.setVariable(name, current + value);
|
||||
}
|
||||
|
||||
setTemp(name: string, value: any): void {
|
||||
this.tempVars.set(name, value);
|
||||
}
|
||||
|
||||
getTemp(name: string): any {
|
||||
return this.tempVars.get(name);
|
||||
}
|
||||
|
||||
private async buildExpressionScope(): Promise<Record<string, any>> {
|
||||
// Build event namespace
|
||||
const event: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(this.eventData)) {
|
||||
// Try to convert numeric strings to numbers for expression evaluation
|
||||
const num = Number(val);
|
||||
event[key] = isNaN(num) ? val : num;
|
||||
}
|
||||
|
||||
// Build var namespace from DB
|
||||
const varRecords = await this.prisma.botVariable.findMany({
|
||||
where: { flowId: this.flowId, scope: 'flow' },
|
||||
});
|
||||
const vars: Record<string, any> = {};
|
||||
for (const v of varRecords) {
|
||||
const num = Number(v.value);
|
||||
vars[v.name] = isNaN(num) ? v.value : num;
|
||||
}
|
||||
|
||||
// Build temp namespace
|
||||
const temp: Record<string, any> = {};
|
||||
for (const [key, val] of this.tempVars.entries()) {
|
||||
temp[key] = val;
|
||||
}
|
||||
|
||||
// Build time namespace
|
||||
const tv = getTimeValues(this.timezone);
|
||||
const time: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(tv)) {
|
||||
const num = Number(v);
|
||||
time[k] = isNaN(num) ? v : num;
|
||||
}
|
||||
|
||||
return { event, var: vars, temp, time };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
import { EventBridge } from './event-bridge.js';
|
||||
import { FlowRunner } from './flow-runner.js';
|
||||
import type { Express, Request, Response } from 'express';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
import cron from 'node-cron';
|
||||
import type {
|
||||
FlowDefinition, FlowNode, FlowEdge,
|
||||
EventTriggerData, CronTriggerData, WebhookTriggerData, CommandTriggerData,
|
||||
AnimatedChannelActionData,
|
||||
} from '@ts6/common';
|
||||
import { AnimationManager } from './animation-manager.js';
|
||||
import type { AnimationConfig } from './animation-manager.js';
|
||||
|
||||
/**
|
||||
* Normalize flow data from the frontend editor format to the engine format.
|
||||
*
|
||||
* Editor format:
|
||||
* node: { id, type: "trigger_event"/"action_kick"/etc, label, config: { eventName, reason, ... }, x, y }
|
||||
* edge: { id, source, sourcePort, target, targetPort }
|
||||
*
|
||||
* Engine format (matches @ts6/common types):
|
||||
* node: { id, type: "trigger"/"action"/etc, position: {x,y}, data: { triggerType/actionType, label, ...config } }
|
||||
* edge: { id, source, target, sourceHandle }
|
||||
*
|
||||
* Config field mappings:
|
||||
* trigger_event: { eventName } → { triggerType:'event', eventName }
|
||||
* trigger_cron: { cron } → { triggerType:'cron', cronExpression: cron }
|
||||
* trigger_webhook: { path } → { triggerType:'webhook', webhookPath: path }
|
||||
* trigger_command: { command } → { triggerType:'command', commandPrefix:'!', commandName: command }
|
||||
* action_kick: { reasonid, reason } → { actionType:'kick', reasonId: parseInt(reasonid)||5, reasonMsg: reason }
|
||||
* action_ban: { time, reason } → { actionType:'ban', duration: time, reason }
|
||||
* action_move: { channelId } → { actionType:'move', channelId }
|
||||
* action_message: { targetMode, message } → { actionType:'message', targetMode: modeMap, message }
|
||||
* condition: { expression } → { nodeType:'condition', expression }
|
||||
* delay: { delay } → { nodeType:'delay', delayMs: delay }
|
||||
* variable: { operation, name, value } → { nodeType:'variable', operation, variableName: name, value }
|
||||
* log: { level, message } → { nodeType:'log', level, message }
|
||||
*/
|
||||
function normalizeFlowData(raw: any): FlowDefinition {
|
||||
const targetModeMap: Record<string, number> = { client: 1, channel: 2, server: 3 };
|
||||
|
||||
const nodes: FlowNode[] = (raw.nodes || []).map((n: any) => {
|
||||
const nodeType: string = n.type || '';
|
||||
|
||||
// Already in engine format?
|
||||
if (n.data && (n.data.triggerType || n.data.actionType || n.data.nodeType)) {
|
||||
return { ...n, position: n.position || { x: n.x || 0, y: n.y || 0 } };
|
||||
}
|
||||
|
||||
const config = n.config || {};
|
||||
const label = n.label || nodeType.replace(/_/g, ' ');
|
||||
const position = n.position || { x: n.x || 0, y: n.y || 0 };
|
||||
let type: string;
|
||||
let data: any;
|
||||
|
||||
if (nodeType === 'trigger_event') {
|
||||
type = 'trigger';
|
||||
data = { triggerType: 'event', label, eventName: config.eventName || '', filters: config.filters };
|
||||
} else if (nodeType === 'trigger_cron') {
|
||||
type = 'trigger';
|
||||
data = { triggerType: 'cron', label, cronExpression: config.cron || config.cronExpression || '', timezone: config.timezone || undefined };
|
||||
} else if (nodeType === 'trigger_webhook') {
|
||||
type = 'trigger';
|
||||
data = { triggerType: 'webhook', label, webhookPath: config.path || config.webhookPath || '', method: config.method || 'POST', secret: config.secret || undefined };
|
||||
} else if (nodeType === 'trigger_command') {
|
||||
type = 'trigger';
|
||||
const cmd = config.command || '';
|
||||
const prefix = cmd.startsWith('!') ? '!' : config.commandPrefix || '!';
|
||||
const name = cmd.startsWith('!') ? cmd.substring(1) : cmd;
|
||||
data = { triggerType: 'command', label, commandPrefix: prefix, commandName: name };
|
||||
} else if (nodeType === 'action_kick') {
|
||||
type = 'action';
|
||||
data = { actionType: 'kick', label, reasonId: parseInt(config.reasonid) || 5, reasonMsg: config.reason || '' };
|
||||
} else if (nodeType === 'action_ban') {
|
||||
type = 'action';
|
||||
data = { actionType: 'ban', label, duration: config.time || 0, reason: config.reason || '' };
|
||||
} else if (nodeType === 'action_move') {
|
||||
type = 'action';
|
||||
data = { actionType: 'move', label, channelId: config.channelId || config.cid || '' };
|
||||
} else if (nodeType === 'action_message') {
|
||||
type = 'action';
|
||||
data = { actionType: 'message', label, targetMode: targetModeMap[config.targetMode] || 1, message: config.message || '', target: config.target };
|
||||
} else if (nodeType === 'action_poke') {
|
||||
type = 'action';
|
||||
data = { actionType: 'poke', label, message: config.message || '' };
|
||||
} else if (nodeType === 'action_channelCreate') {
|
||||
type = 'action';
|
||||
const params: Record<string, string> = {};
|
||||
if (config.channel_name) params.channel_name = config.channel_name;
|
||||
if (config.cpid) params.cpid = config.cpid;
|
||||
if (config.channel_flag_temporary) params.channel_flag_temporary = config.channel_flag_temporary;
|
||||
if (config.channel_flag_semi_permanent) params.channel_flag_semi_permanent = config.channel_flag_semi_permanent;
|
||||
if (config.channel_topic) params.channel_topic = config.channel_topic;
|
||||
data = { actionType: 'channelCreate', label, params: { ...params, ...config.params } };
|
||||
} else if (nodeType === 'action_channelEdit') {
|
||||
type = 'action';
|
||||
const params: Record<string, string> = {};
|
||||
if (config.channel_name) params.channel_name = config.channel_name;
|
||||
if (config.channel_topic) params.channel_topic = config.channel_topic;
|
||||
if (config.channel_description) params.channel_description = config.channel_description;
|
||||
if (config.channel_maxclients) params.channel_maxclients = config.channel_maxclients;
|
||||
data = { actionType: 'channelEdit', label, channelId: config.channelId || config.cid || '', params: { ...params, ...config.params } };
|
||||
} else if (nodeType === 'action_channelDelete') {
|
||||
type = 'action';
|
||||
data = { actionType: 'channelDelete', label, channelId: config.channelId || config.cid || '', force: !!config.force };
|
||||
} else if (nodeType === 'action_groupAdd') {
|
||||
type = 'action';
|
||||
data = { actionType: 'groupAddClient', label, groupId: config.groupId || '' };
|
||||
} else if (nodeType === 'action_groupRemove') {
|
||||
type = 'action';
|
||||
data = { actionType: 'groupRemoveClient', label, groupId: config.groupId || '' };
|
||||
} else if (nodeType === 'action_webquery') {
|
||||
type = 'action';
|
||||
data = { actionType: 'webquery', label, command: config.command || '', params: config.params || {}, storeAs: config.storeAs || undefined };
|
||||
} else if (nodeType === 'action_webhook') {
|
||||
type = 'action';
|
||||
data = { actionType: 'webhook', label, url: config.url || '', method: config.method || 'POST', headers: config.headers, body: config.body, storeAs: config.storeAs || undefined };
|
||||
} else if (nodeType === 'action_httpRequest') {
|
||||
type = 'action';
|
||||
data = { actionType: 'httpRequest', label, url: config.url || '', method: config.method || 'GET', headers: config.headers, body: config.body, storeAs: config.storeAs || undefined };
|
||||
} else if (nodeType === 'action_afkMover') {
|
||||
type = 'action';
|
||||
data = { actionType: 'afkMover', label, afkChannelId: config.afkChannelId || '', idleThresholdSeconds: parseInt(config.idleThresholdSeconds) || 300, exemptGroupIds: config.exemptGroupIds || '' };
|
||||
} else if (nodeType === 'action_idleKicker') {
|
||||
type = 'action';
|
||||
data = { actionType: 'idleKicker', label, idleThresholdSeconds: parseInt(config.idleThresholdSeconds) || 1800, reason: config.reason || '', exemptGroupIds: config.exemptGroupIds || '' };
|
||||
} else if (nodeType === 'action_pokeGroup') {
|
||||
type = 'action';
|
||||
data = { actionType: 'pokeGroup', label, groupId: config.groupId || '', message: config.message || '' };
|
||||
} else if (nodeType === 'action_rankCheck') {
|
||||
type = 'action';
|
||||
data = { actionType: 'rankCheck', label, ranks: config.ranks || '[]' };
|
||||
} else if (nodeType === 'action_tempChannelCleanup') {
|
||||
type = 'action';
|
||||
data = { actionType: 'tempChannelCleanup', label, parentChannelId: config.parentChannelId || '', protectedChannelIds: config.protectedChannelIds || '' };
|
||||
} else if (nodeType === 'action_voicePlay') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voicePlay', label, botId: config.botId || '', songId: config.songId || '', playlistId: config.playlistId || '' };
|
||||
} else if (nodeType === 'action_voiceStop') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceStop', label, botId: config.botId || '' };
|
||||
} else if (nodeType === 'action_voiceJoinChannel') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceJoinChannel', label, botId: config.botId || '', channelId: config.channelId || '', channelPassword: config.channelPassword || '' };
|
||||
} else if (nodeType === 'action_voiceLeaveChannel') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceLeaveChannel', label, botId: config.botId || '' };
|
||||
} else if (nodeType === 'action_voiceVolume') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceVolume', label, botId: config.botId || '', volume: config.volume || '50' };
|
||||
} else if (nodeType === 'action_voicePauseResume') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voicePauseResume', label, botId: config.botId || '', action: config.action || 'toggle' };
|
||||
} else if (nodeType === 'action_voiceSkip') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceSkip', label, botId: config.botId || '', direction: config.direction || 'next' };
|
||||
} else if (nodeType === 'action_voiceSeek') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceSeek', label, botId: config.botId || '', position: config.position || '0' };
|
||||
} else if (nodeType === 'action_voiceTts') {
|
||||
type = 'action';
|
||||
data = { actionType: 'voiceTts', label, botId: config.botId || '', text: config.text || '', language: config.language || '' };
|
||||
} else if (nodeType === 'action_animatedChannel') {
|
||||
type = 'action';
|
||||
data = { actionType: 'animatedChannel', label, channelId: config.channelId || '', text: config.text || '', style: config.style || 'scroll', intervalSeconds: config.intervalSeconds || '3', prefix: config.prefix || '[cspacer]' };
|
||||
} else if (nodeType === 'condition') {
|
||||
type = 'condition';
|
||||
data = { nodeType: 'condition', label, expression: config.expression || '' };
|
||||
} else if (nodeType === 'delay') {
|
||||
type = 'delay';
|
||||
data = { nodeType: 'delay', label, delayMs: config.delay || config.delayMs || 1000 };
|
||||
} else if (nodeType === 'variable') {
|
||||
type = 'variable';
|
||||
data = { nodeType: 'variable', label, operation: config.operation || 'set', variableName: config.name || '', value: config.value || '' };
|
||||
} else if (nodeType === 'log') {
|
||||
type = 'log';
|
||||
data = { nodeType: 'log', label, level: config.level || 'info', message: config.message || '' };
|
||||
} else {
|
||||
// Pass through unknown types
|
||||
type = nodeType;
|
||||
data = { label, ...config };
|
||||
}
|
||||
|
||||
return { id: n.id, type, position, data } as FlowNode;
|
||||
});
|
||||
|
||||
const edges: FlowEdge[] = (raw.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle || e.sourcePort || undefined,
|
||||
label: e.label || undefined,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
interface LoadedFlow {
|
||||
id: number;
|
||||
name: string;
|
||||
serverConfigId: number;
|
||||
virtualServerId: number;
|
||||
flowData: FlowDefinition;
|
||||
triggerNodes: FlowNode[];
|
||||
}
|
||||
|
||||
interface CronEntry {
|
||||
flowId: number;
|
||||
nodeId: string;
|
||||
task: cron.ScheduledTask;
|
||||
}
|
||||
|
||||
interface WebhookEntry {
|
||||
flowId: number;
|
||||
nodeId: string;
|
||||
path: string;
|
||||
method: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_PER_FLOW = 20;
|
||||
|
||||
export class BotEngine {
|
||||
private flows: Map<number, LoadedFlow> = new Map();
|
||||
private eventBridge: EventBridge;
|
||||
private flowRunner: FlowRunner;
|
||||
private animationManager: AnimationManager;
|
||||
private cronJobs: CronEntry[] = [];
|
||||
private webhookEntries: WebhookEntry[] = [];
|
||||
private executionCounts: Map<number, number> = new Map();
|
||||
private running: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private connectionPool: ConnectionPool,
|
||||
private wss: WebSocketServer,
|
||||
private app: Express,
|
||||
) {
|
||||
this.eventBridge = new EventBridge(prisma);
|
||||
this.flowRunner = new FlowRunner(prisma, connectionPool, wss);
|
||||
this.animationManager = new AnimationManager();
|
||||
}
|
||||
|
||||
setVoiceBotManager(manager: any): void {
|
||||
this.flowRunner.setVoiceBotManager(manager);
|
||||
}
|
||||
|
||||
getEventBridge(): EventBridge {
|
||||
return this.eventBridge;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
// Always register event listener (even if no flows yet — flows can be enabled later)
|
||||
this.eventBridge.on('tsEvent', this.onTsEvent.bind(this));
|
||||
|
||||
await this.loadFlows();
|
||||
|
||||
if (this.flows.size === 0) {
|
||||
console.log('[BotEngine] No enabled flows found, engine idle (will activate when flows are enabled)');
|
||||
this.running = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup SSH connections for all unique server+vserver pairs (non-blocking)
|
||||
this.setupSshConnections();
|
||||
|
||||
// Setup cron jobs
|
||||
this.setupCronJobs();
|
||||
|
||||
// Build webhook registry
|
||||
this.buildWebhookRegistry();
|
||||
|
||||
// Start animations for all loaded flows
|
||||
for (const flow of this.flows.values()) {
|
||||
this.startAnimationsForFlow(flow.id, flow.serverConfigId, flow.virtualServerId);
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
const sshCount = this.eventBridge.getConnectedKeys().length;
|
||||
console.log(`[BotEngine] Started with ${this.flows.size} flow(s), ${sshCount} SSH connection(s), ${this.cronJobs.length} cron job(s), ${this.webhookEntries.length} webhook(s)`);
|
||||
|
||||
this.broadcast('bot:engine:started', { flowCount: this.flows.size });
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
this.animationManager.stopAll();
|
||||
this.teardownCronJobs();
|
||||
this.webhookEntries = [];
|
||||
this.eventBridge.removeAllListeners('tsEvent');
|
||||
this.flows.clear();
|
||||
this.executionCounts.clear();
|
||||
}
|
||||
|
||||
async enableFlow(flowId: number): Promise<void> {
|
||||
console.log(`[BotEngine] Enabling flow ${flowId}...`);
|
||||
const dbFlow = await this.prisma.botFlow.findUnique({ where: { id: flowId } });
|
||||
if (!dbFlow || !dbFlow.enabled) {
|
||||
console.log(`[BotEngine] Flow ${flowId} not found or not enabled in DB`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(dbFlow.flowData);
|
||||
const flowData = normalizeFlowData(raw);
|
||||
const triggerNodes = flowData.nodes.filter(n => n.type === 'trigger');
|
||||
|
||||
const hasAnimations = flowData.nodes.some(n => n.type === 'action' && (n.data as any).actionType === 'animatedChannel');
|
||||
console.log(`[BotEngine] Flow ${flowId} ('${dbFlow.name}'): ${triggerNodes.length} trigger(s), ${flowData.nodes.length} node(s), ${flowData.edges.length} edge(s)${hasAnimations ? ', has animations' : ''}`);
|
||||
|
||||
if (triggerNodes.length === 0 && !hasAnimations) {
|
||||
console.warn(`[BotEngine] Flow ${flowId} has no trigger nodes and no animations — nothing to activate`);
|
||||
}
|
||||
|
||||
for (const t of triggerNodes) {
|
||||
const td = t.data as any;
|
||||
console.log(`[BotEngine] Trigger: type=${td.triggerType}, ${td.triggerType === 'event' ? `event=${td.eventName}` : td.triggerType === 'cron' ? `cron=${td.cronExpression}` : td.triggerType === 'command' ? `cmd=${td.commandPrefix}${td.commandName}` : `webhook=${td.webhookPath}`}`);
|
||||
}
|
||||
|
||||
this.flows.set(flowId, {
|
||||
id: dbFlow.id,
|
||||
name: dbFlow.name,
|
||||
serverConfigId: dbFlow.serverConfigId,
|
||||
virtualServerId: dbFlow.virtualServerId,
|
||||
flowData,
|
||||
triggerNodes,
|
||||
});
|
||||
|
||||
// Ensure SSH connection exists for event/command triggers
|
||||
const hasEventTrigger = triggerNodes.some(t => {
|
||||
const td = t.data as any;
|
||||
return td.triggerType === 'event' || td.triggerType === 'command';
|
||||
});
|
||||
|
||||
if (hasEventTrigger) {
|
||||
console.log(`[BotEngine] Flow needs SSH — connecting to server ${dbFlow.serverConfigId}, sid=${dbFlow.virtualServerId}...`);
|
||||
if (!this.eventBridge.isConnected(dbFlow.serverConfigId, dbFlow.virtualServerId)) {
|
||||
// Non-blocking: SSH connects in background, events will flow once connected
|
||||
this.eventBridge.connectServer(dbFlow.serverConfigId, dbFlow.virtualServerId).catch(err => {
|
||||
console.error(`[BotEngine] SSH connection failed for ${dbFlow.serverConfigId}:${dbFlow.virtualServerId}: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
console.log(`[BotEngine] SSH already connected for ${dbFlow.serverConfigId}:${dbFlow.virtualServerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup cron jobs for this flow
|
||||
this.setupCronJobsForFlow(flowId);
|
||||
|
||||
// Add webhook entries for this flow
|
||||
this.buildWebhookRegistryForFlow(flowId);
|
||||
|
||||
// Start animations for any animatedChannel action nodes
|
||||
this.startAnimationsForFlow(flowId, dbFlow.serverConfigId, dbFlow.virtualServerId);
|
||||
|
||||
console.log(`[BotEngine] Flow ${flowId} ('${dbFlow.name}') enabled successfully`);
|
||||
} catch (err: any) {
|
||||
console.error(`[BotEngine] Failed to enable flow ${flowId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async disableFlow(flowId: number): Promise<void> {
|
||||
this.animationManager.stopAnimation(flowId);
|
||||
this.teardownCronJobs(flowId);
|
||||
this.webhookEntries = this.webhookEntries.filter(w => w.flowId !== flowId);
|
||||
this.flows.delete(flowId);
|
||||
this.executionCounts.delete(flowId);
|
||||
|
||||
// Check if any remaining flows use the same SSH connections
|
||||
await this.cleanupUnusedSshConnections();
|
||||
|
||||
console.log(`[BotEngine] Flow ${flowId} disabled`);
|
||||
}
|
||||
|
||||
async reloadFlow(flowId: number): Promise<void> {
|
||||
const flow = this.flows.get(flowId);
|
||||
if (flow) {
|
||||
// Flow was active — disable then re-enable
|
||||
await this.disableFlow(flowId);
|
||||
const dbFlow = await this.prisma.botFlow.findUnique({ where: { id: flowId } });
|
||||
if (dbFlow?.enabled) {
|
||||
await this.enableFlow(flowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleWebhookRequest(req: Request, res: Response): void {
|
||||
const webhookPath = String(req.params.path || req.params[0] || '');
|
||||
const method = req.method.toUpperCase();
|
||||
const providedSecret = String(req.headers['x-webhook-secret'] || req.query.secret || '');
|
||||
|
||||
const matching = this.webhookEntries.filter(w =>
|
||||
w.path === webhookPath && (w.method === method || w.method === 'ANY')
|
||||
);
|
||||
|
||||
if (matching.length === 0) {
|
||||
res.status(404).json({ error: 'No webhook handler found' });
|
||||
return;
|
||||
}
|
||||
|
||||
let triggered = 0;
|
||||
let rejected = 0;
|
||||
for (const wh of matching) {
|
||||
if (wh.secret && wh.secret !== providedSecret) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const flow = this.flows.get(wh.flowId);
|
||||
if (!flow) continue;
|
||||
|
||||
const webhookData: Record<string, string> = {
|
||||
webhook_path: webhookPath,
|
||||
webhook_method: method,
|
||||
webhook_body: JSON.stringify(req.body || {}),
|
||||
webhook_query: JSON.stringify(req.query || {}),
|
||||
};
|
||||
|
||||
this.executeFlow(flow, wh.nodeId, 'webhook', webhookData);
|
||||
triggered++;
|
||||
}
|
||||
|
||||
if (triggered === 0 && rejected > 0) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ triggered });
|
||||
}
|
||||
|
||||
getFlow(flowId: number): LoadedFlow | undefined {
|
||||
return this.flows.get(flowId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.eventBridge.destroy();
|
||||
this.broadcast('bot:engine:stopped', {});
|
||||
}
|
||||
|
||||
// --- Private Methods ---
|
||||
|
||||
private async loadFlows(): Promise<void> {
|
||||
const dbFlows = await this.prisma.botFlow.findMany({ where: { enabled: true } });
|
||||
|
||||
for (const dbFlow of dbFlows) {
|
||||
try {
|
||||
const raw = JSON.parse(dbFlow.flowData);
|
||||
const flowData = normalizeFlowData(raw);
|
||||
const triggerNodes = flowData.nodes.filter(n => n.type === 'trigger');
|
||||
const hasAnimations = flowData.nodes.some(n => n.type === 'action' && (n.data as any).actionType === 'animatedChannel');
|
||||
|
||||
if (triggerNodes.length === 0 && !hasAnimations) {
|
||||
console.warn(`[BotEngine] Flow ${dbFlow.id} ('${dbFlow.name}') has no trigger nodes and no animations, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.flows.set(dbFlow.id, {
|
||||
id: dbFlow.id,
|
||||
name: dbFlow.name,
|
||||
serverConfigId: dbFlow.serverConfigId,
|
||||
virtualServerId: dbFlow.virtualServerId,
|
||||
flowData,
|
||||
triggerNodes,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[BotEngine] Failed to parse flow ${dbFlow.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupSshConnections(): void {
|
||||
const serverPairs = new Set<string>();
|
||||
for (const flow of this.flows.values()) {
|
||||
serverPairs.add(`${flow.serverConfigId}:${flow.virtualServerId}`);
|
||||
}
|
||||
|
||||
for (const pair of serverPairs) {
|
||||
const [configId, sid] = pair.split(':').map(Number);
|
||||
// Non-blocking: don't await SSH connections during startup
|
||||
this.eventBridge.connectServer(configId, sid).catch(err => {
|
||||
console.error(`[BotEngine] SSH connection failed for ${pair}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupUnusedSshConnections(): Promise<void> {
|
||||
const neededKeys = new Set<string>();
|
||||
for (const flow of this.flows.values()) {
|
||||
neededKeys.add(`${flow.serverConfigId}:${flow.virtualServerId}`);
|
||||
}
|
||||
|
||||
for (const key of this.eventBridge.getConnectedKeys()) {
|
||||
if (!neededKeys.has(key)) {
|
||||
const [configId, sid] = key.split(':').map(Number);
|
||||
await this.eventBridge.disconnectServer(configId, sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onTsEvent(configId: number, sid: number, eventName: string, data: Record<string, string>): void {
|
||||
console.log(`[BotEngine] TS Event received: ${eventName} from ${configId}:${sid}`, JSON.stringify(data).substring(0, 200));
|
||||
for (const flow of this.flows.values()) {
|
||||
if (flow.serverConfigId !== configId || flow.virtualServerId !== sid) continue;
|
||||
|
||||
for (const triggerNode of flow.triggerNodes) {
|
||||
const triggerData = triggerNode.data as any;
|
||||
|
||||
// Event trigger
|
||||
if (triggerData.triggerType === 'event') {
|
||||
const eventTrigger = triggerData as EventTriggerData;
|
||||
if (eventTrigger.eventName !== eventName) {
|
||||
console.log(`[BotEngine] Flow ${flow.id}: trigger wants '${eventTrigger.eventName}', got '${eventName}' — skip`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (eventTrigger.filters) {
|
||||
let matches = true;
|
||||
for (const [key, value] of Object.entries(eventTrigger.filters)) {
|
||||
if (data[key] !== value) { matches = false; break; }
|
||||
}
|
||||
if (!matches) continue;
|
||||
}
|
||||
|
||||
this.executeFlow(flow, triggerNode.id, 'event', data);
|
||||
}
|
||||
|
||||
// Command trigger (special case of text message)
|
||||
if (triggerData.triggerType === 'command' && eventName === 'notifytextmessage') {
|
||||
const cmdTrigger = triggerData as CommandTriggerData;
|
||||
const msg = data.msg || '';
|
||||
const fullCommand = (cmdTrigger.commandPrefix || '!') + cmdTrigger.commandName;
|
||||
|
||||
if (!msg.startsWith(fullCommand)) continue;
|
||||
|
||||
// Check that the command is followed by a space or is the entire message
|
||||
const afterCmd = msg.substring(fullCommand.length);
|
||||
if (afterCmd.length > 0 && afterCmd[0] !== ' ') continue;
|
||||
|
||||
const args = afterCmd.trim();
|
||||
const enrichedData = { ...data, command_args: args, command_name: cmdTrigger.commandName };
|
||||
this.executeFlow(flow, triggerNode.id, 'command', enrichedData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private executeFlow(flow: LoadedFlow, triggerNodeId: string, triggerType: string, eventData: Record<string, string>): void {
|
||||
console.log(`[BotEngine] Executing flow ${flow.id} ('${flow.name}') triggered by ${triggerType}`);
|
||||
|
||||
// Rate limiting
|
||||
const current = this.executionCounts.get(flow.id) || 0;
|
||||
if (current >= MAX_CONCURRENT_PER_FLOW) {
|
||||
console.warn(`[BotEngine] Flow ${flow.id} rate limited (${current} concurrent executions)`);
|
||||
return;
|
||||
}
|
||||
this.executionCounts.set(flow.id, current + 1);
|
||||
|
||||
// Extract timezone from the trigger node (if cron trigger has one)
|
||||
const triggerNode = flow.flowData.nodes.find(n => n.id === triggerNodeId);
|
||||
const triggerData = triggerNode?.data as any;
|
||||
const timezone = triggerData?.timezone as string | undefined;
|
||||
|
||||
this.flowRunner.execute(flow, triggerNodeId, triggerType, eventData, timezone)
|
||||
.catch(err => {
|
||||
console.error(`[BotEngine] Flow ${flow.id} execution error: ${err.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
const count = this.executionCounts.get(flow.id) || 1;
|
||||
this.executionCounts.set(flow.id, count - 1);
|
||||
});
|
||||
}
|
||||
|
||||
private setupCronJobs(): void {
|
||||
for (const flow of this.flows.values()) {
|
||||
this.setupCronJobsForFlow(flow.id);
|
||||
}
|
||||
}
|
||||
|
||||
private setupCronJobsForFlow(flowId: number): void {
|
||||
const flow = this.flows.get(flowId);
|
||||
if (!flow) return;
|
||||
|
||||
for (const triggerNode of flow.triggerNodes) {
|
||||
const triggerData = triggerNode.data as any;
|
||||
if (triggerData.triggerType !== 'cron') continue;
|
||||
|
||||
const cronData = triggerData as CronTriggerData;
|
||||
if (!cron.validate(cronData.cronExpression)) {
|
||||
console.error(`[BotEngine] Invalid cron expression '${cronData.cronExpression}' in flow ${flowId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = cron.schedule(cronData.cronExpression, () => {
|
||||
this.executeFlow(flow, triggerNode.id, 'cron', {});
|
||||
}, {
|
||||
timezone: cronData.timezone || 'UTC',
|
||||
});
|
||||
|
||||
this.cronJobs.push({ flowId, nodeId: triggerNode.id, task });
|
||||
}
|
||||
}
|
||||
|
||||
private buildWebhookRegistry(): void {
|
||||
for (const flow of this.flows.values()) {
|
||||
this.buildWebhookRegistryForFlow(flow.id);
|
||||
}
|
||||
}
|
||||
|
||||
private buildWebhookRegistryForFlow(flowId: number): void {
|
||||
const flow = this.flows.get(flowId);
|
||||
if (!flow) return;
|
||||
|
||||
for (const triggerNode of flow.triggerNodes) {
|
||||
const triggerData = triggerNode.data as any;
|
||||
if (triggerData.triggerType !== 'webhook') continue;
|
||||
|
||||
const whData = triggerData as WebhookTriggerData;
|
||||
this.webhookEntries.push({
|
||||
flowId,
|
||||
nodeId: triggerNode.id,
|
||||
path: whData.webhookPath,
|
||||
method: whData.method || 'POST',
|
||||
secret: whData.secret || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private teardownCronJobs(flowId?: number): void {
|
||||
const toRemove = flowId !== undefined
|
||||
? this.cronJobs.filter(j => j.flowId === flowId)
|
||||
: this.cronJobs;
|
||||
|
||||
for (const entry of toRemove) {
|
||||
entry.task.stop();
|
||||
}
|
||||
|
||||
this.cronJobs = flowId !== undefined
|
||||
? this.cronJobs.filter(j => j.flowId !== flowId)
|
||||
: [];
|
||||
}
|
||||
|
||||
private startAnimationsForFlow(flowId: number, serverConfigId: number, virtualServerId: number): void {
|
||||
const flow = this.flows.get(flowId);
|
||||
if (!flow) return;
|
||||
|
||||
const animNodes = flow.flowData.nodes.filter(
|
||||
n => n.type === 'action' && (n.data as any).actionType === 'animatedChannel'
|
||||
);
|
||||
|
||||
if (animNodes.length === 0) return;
|
||||
|
||||
try {
|
||||
const client = this.connectionPool.getClient(serverConfigId);
|
||||
|
||||
for (const node of animNodes) {
|
||||
const d = node.data as AnimatedChannelActionData;
|
||||
const config: AnimationConfig = {
|
||||
channelId: d.channelId,
|
||||
text: d.text,
|
||||
style: d.style || 'scroll',
|
||||
intervalSeconds: parseInt(d.intervalSeconds) || 3,
|
||||
prefix: d.prefix || '[cspacer]',
|
||||
};
|
||||
|
||||
this.animationManager.startAnimation(flowId, virtualServerId, config, client);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[BotEngine] Failed to start animations for flow ${flowId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(type: string, payload: any): void {
|
||||
const msg = JSON.stringify({ type, ...payload });
|
||||
this.wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import { SshQueryClient } from './ssh-query-client.js';
|
||||
|
||||
export declare interface EventBridge {
|
||||
on(event: 'tsEvent', listener: (configId: number, sid: number, eventName: string, data: Record<string, string>) => void): this;
|
||||
on(event: 'sshConnected', listener: (configId: number, sid: number) => void): this;
|
||||
on(event: 'sshDisconnected', listener: (configId: number, sid: number) => void): this;
|
||||
on(event: 'sshError', listener: (configId: number, sid: number, err: Error) => void): this;
|
||||
emit(event: 'tsEvent', configId: number, sid: number, eventName: string, data: Record<string, string>): boolean;
|
||||
emit(event: 'sshConnected', configId: number, sid: number): boolean;
|
||||
emit(event: 'sshDisconnected', configId: number, sid: number): boolean;
|
||||
emit(event: 'sshError', configId: number, sid: number, err: Error): boolean;
|
||||
}
|
||||
|
||||
export class EventBridge extends EventEmitter {
|
||||
private connections: Map<string, SshQueryClient> = new Map();
|
||||
|
||||
constructor(private prisma: PrismaClient) {
|
||||
super();
|
||||
}
|
||||
|
||||
private makeKey(configId: number, sid: number): string {
|
||||
return `${configId}:${sid}`;
|
||||
}
|
||||
|
||||
async connectServer(configId: number, sid: number): Promise<void> {
|
||||
const key = this.makeKey(configId, sid);
|
||||
if (this.connections.has(key)) return;
|
||||
|
||||
const serverConfig = await this.prisma.tsServerConfig.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
|
||||
if (!serverConfig) {
|
||||
console.warn(`[EventBridge] Server config ${configId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverConfig.sshUsername || !serverConfig.sshPassword || !serverConfig.sshPort) {
|
||||
console.warn(`[EventBridge] Server config ${configId} has no SSH credentials, skipping SSH connection`);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SshQueryClient({
|
||||
host: serverConfig.host,
|
||||
port: serverConfig.sshPort,
|
||||
username: serverConfig.sshUsername,
|
||||
password: serverConfig.sshPassword,
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
console.log(`[EventBridge] SSH connected to ${serverConfig.host}:${serverConfig.sshPort} for sid=${sid}`);
|
||||
try {
|
||||
await client.registerEvents(sid);
|
||||
this.emit('sshConnected', configId, sid);
|
||||
} catch (err: any) {
|
||||
console.error(`[EventBridge] Failed to register events for ${key}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('event', (eventName: string, data: Record<string, string>) => {
|
||||
this.emit('tsEvent', configId, sid, eventName, data);
|
||||
});
|
||||
|
||||
client.on('error', (err: Error) => {
|
||||
console.error(`[EventBridge] SSH error for ${key}: ${err.message}`);
|
||||
this.emit('sshError', configId, sid, err);
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log(`[EventBridge] SSH disconnected for ${key}`);
|
||||
this.emit('sshDisconnected', configId, sid);
|
||||
});
|
||||
|
||||
this.connections.set(key, client);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (err: any) {
|
||||
console.error(`[EventBridge] Initial SSH connection failed for ${key}: ${err.message}`);
|
||||
// Auto-reconnect is handled internally by SshQueryClient (unless fatal)
|
||||
if (client.hasFatalError) {
|
||||
this.connections.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectServer(configId: number, sid: number): Promise<void> {
|
||||
const key = this.makeKey(configId, sid);
|
||||
const client = this.connections.get(key);
|
||||
if (client) {
|
||||
client.destroy();
|
||||
this.connections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(configId: number, sid: number): boolean {
|
||||
const key = this.makeKey(configId, sid);
|
||||
const client = this.connections.get(key);
|
||||
return client?.isConnected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw ServerQuery command on an existing (or on-demand) SSH connection.
|
||||
* Reuses the same connection used for event listening — no extra server slots.
|
||||
*/
|
||||
async executeCommand(configId: number, sid: number, command: string): Promise<string> {
|
||||
const key = this.makeKey(configId, sid);
|
||||
let client = this.connections.get(key);
|
||||
|
||||
// Connect on demand if no connection exists yet
|
||||
if (!client || !client.isConnected) {
|
||||
await this.connectServer(configId, sid);
|
||||
client = this.connections.get(key);
|
||||
if (!client || !client.isConnected) {
|
||||
throw new Error('SSH not connected — check SSH credentials in server settings');
|
||||
}
|
||||
}
|
||||
|
||||
return client.executeCommand(command);
|
||||
}
|
||||
|
||||
getConnectedKeys(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const [key, client] of this.connections.entries()) {
|
||||
client.destroy();
|
||||
}
|
||||
this.connections.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
import type { WebQueryClient } from '../ts-client/webquery-client.js';
|
||||
import type { VoiceBotManager } from '../voice/voice-bot-manager.js';
|
||||
import { ExecutionContext } from './context.js';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
import type {
|
||||
FlowDefinition, FlowNode, FlowEdge, NodeData,
|
||||
KickActionData, BanActionData, MoveActionData, MessageActionData,
|
||||
PokeActionData, ChannelCreateActionData, ChannelEditActionData, ChannelDeleteActionData,
|
||||
GroupAddClientActionData, GroupRemoveClientActionData,
|
||||
WebQueryActionData, WebhookActionData, HttpRequestActionData,
|
||||
AfkMoverActionData, IdleKickerActionData, PokeGroupActionData, RankCheckActionData, TempChannelCleanupActionData,
|
||||
ConditionNodeData, DelayNodeData, VariableNodeData, LogNodeData,
|
||||
} from '@ts6/common';
|
||||
import axios from 'axios';
|
||||
|
||||
const MAX_NODE_VISITS = 100;
|
||||
const MAX_DELAY_MS = 300000; // 5 minutes
|
||||
|
||||
interface FlowInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
serverConfigId: number;
|
||||
virtualServerId: number;
|
||||
flowData: FlowDefinition;
|
||||
}
|
||||
|
||||
export class FlowRunner {
|
||||
private voiceBotManager: VoiceBotManager | null = null;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private connectionPool: ConnectionPool,
|
||||
private wss: WebSocketServer,
|
||||
) {}
|
||||
|
||||
setVoiceBotManager(manager: VoiceBotManager): void {
|
||||
this.voiceBotManager = manager;
|
||||
}
|
||||
|
||||
async execute(
|
||||
flow: FlowInfo,
|
||||
triggerNodeId: string,
|
||||
triggerType: string,
|
||||
eventData: Record<string, string>,
|
||||
timezone?: string,
|
||||
): Promise<void> {
|
||||
const execution = await this.prisma.botExecution.create({
|
||||
data: {
|
||||
flowId: flow.id,
|
||||
triggeredBy: triggerType,
|
||||
triggerData: JSON.stringify(eventData),
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = new ExecutionContext(
|
||||
this.prisma,
|
||||
flow.id,
|
||||
execution.id,
|
||||
flow.serverConfigId,
|
||||
flow.virtualServerId,
|
||||
triggerType,
|
||||
eventData,
|
||||
timezone,
|
||||
);
|
||||
|
||||
this.broadcast('bot:execution:start', {
|
||||
flowId: flow.id,
|
||||
executionId: execution.id,
|
||||
triggeredBy: triggerType,
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const client = this.connectionPool.getClient(flow.serverConfigId);
|
||||
const triggerNode = flow.flowData.nodes.find(n => n.id === triggerNodeId);
|
||||
|
||||
if (!triggerNode) {
|
||||
throw new Error(`Trigger node ${triggerNodeId} not found in flow ${flow.id}`);
|
||||
}
|
||||
|
||||
await this.log(ctx, triggerNode, 'info', `Flow '${flow.name}' triggered by ${triggerType}`);
|
||||
|
||||
let nodeVisits = 0;
|
||||
const visitNode = async (node: FlowNode): Promise<void> => {
|
||||
nodeVisits++;
|
||||
if (nodeVisits > MAX_NODE_VISITS) {
|
||||
throw new Error(`Max node visits (${MAX_NODE_VISITS}) exceeded — possible infinite loop`);
|
||||
}
|
||||
await this.processNode(node, flow.flowData, ctx, client, visitNode);
|
||||
};
|
||||
|
||||
await visitNode(triggerNode);
|
||||
|
||||
await this.prisma.botExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'completed', endedAt: new Date() },
|
||||
});
|
||||
|
||||
this.broadcast('bot:execution:complete', {
|
||||
flowId: flow.id,
|
||||
executionId: execution.id,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await this.log(ctx, null, 'error', `Flow execution failed: ${err.message}`);
|
||||
|
||||
await this.prisma.botExecution.update({
|
||||
where: { id: execution.id },
|
||||
data: { status: 'failed', error: err.message, endedAt: new Date() },
|
||||
});
|
||||
|
||||
this.broadcast('bot:execution:failed', {
|
||||
flowId: flow.id,
|
||||
executionId: execution.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processNode(
|
||||
node: FlowNode,
|
||||
flowDef: FlowDefinition,
|
||||
ctx: ExecutionContext,
|
||||
client: WebQueryClient,
|
||||
visitNode: (node: FlowNode) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await this.log(ctx, node, 'debug', `Processing node '${node.data.label}' (type=${node.type})`);
|
||||
|
||||
switch (node.type) {
|
||||
case 'trigger': {
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'condition': {
|
||||
const condData = node.data as ConditionNodeData;
|
||||
const expression = await ctx.resolveTemplate(condData.expression);
|
||||
const result = await ctx.evaluateCondition(expression);
|
||||
await this.log(ctx, node, 'info', `Condition '${condData.expression}' → ${result}`);
|
||||
|
||||
const handle = result ? 'true' : 'false';
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef, handle);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'action': {
|
||||
await this.executeAction(node, ctx, client);
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delay': {
|
||||
const delayData = node.data as DelayNodeData;
|
||||
const delayMs = Math.min(delayData.delayMs, MAX_DELAY_MS);
|
||||
await this.log(ctx, node, 'info', `Delaying ${delayMs}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'variable': {
|
||||
const varData = node.data as VariableNodeData;
|
||||
const name = await ctx.resolveTemplate(varData.variableName);
|
||||
const value = await ctx.resolveTemplate(varData.value);
|
||||
|
||||
switch (varData.operation) {
|
||||
case 'set': await ctx.setVariable(name, value); break;
|
||||
case 'increment': await ctx.incrementVariable(name, value); break;
|
||||
case 'append': await ctx.appendVariable(name, value); break;
|
||||
}
|
||||
|
||||
await this.log(ctx, node, 'info', `Variable '${name}' ${varData.operation} = '${value}'`);
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'log': {
|
||||
const logData = node.data as LogNodeData;
|
||||
const message = await ctx.resolveTemplate(logData.message);
|
||||
await this.log(ctx, node, logData.level, message);
|
||||
const edges = this.getOutgoingEdges(node.id, flowDef);
|
||||
for (const edge of edges) {
|
||||
const target = this.findNode(edge.target, flowDef);
|
||||
if (target) await visitNode(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(
|
||||
node: FlowNode,
|
||||
ctx: ExecutionContext,
|
||||
client: WebQueryClient,
|
||||
): Promise<void> {
|
||||
const data = node.data as any;
|
||||
const actionType = data.actionType as string;
|
||||
|
||||
await this.log(ctx, node, 'info', `Executing action: ${actionType}`);
|
||||
|
||||
try {
|
||||
switch (actionType) {
|
||||
case 'kick': await this.executeKick(data as KickActionData, ctx, client); break;
|
||||
case 'ban': await this.executeBan(data as BanActionData, ctx, client); break;
|
||||
case 'move': await this.executeMove(data as MoveActionData, ctx, client); break;
|
||||
case 'message': await this.executeMessage(data as MessageActionData, ctx, client); break;
|
||||
case 'poke': await this.executePoke(data as PokeActionData, ctx, client); break;
|
||||
case 'channelCreate': await this.executeChannelCreate(data as ChannelCreateActionData, ctx, client); break;
|
||||
case 'groupAddClient': await this.executeGroupAddClient(data as GroupAddClientActionData, ctx, client); break;
|
||||
case 'groupRemoveClient': await this.executeGroupRemoveClient(data as GroupRemoveClientActionData, ctx, client); break;
|
||||
case 'webquery': await this.executeWebQuery(data as WebQueryActionData, ctx, client); break;
|
||||
case 'webhook': await this.executeWebhook(data as WebhookActionData, ctx); break;
|
||||
case 'channelEdit': await this.executeChannelEdit(data as ChannelEditActionData, ctx, client); break;
|
||||
case 'channelDelete': await this.executeChannelDelete(data as ChannelDeleteActionData, ctx, client); break;
|
||||
case 'httpRequest': await this.executeHttpRequest(data as HttpRequestActionData, ctx); break;
|
||||
case 'afkMover': await this.executeAfkMover(data as AfkMoverActionData, ctx, client); break;
|
||||
case 'idleKicker': await this.executeIdleKicker(data as IdleKickerActionData, ctx, client); break;
|
||||
case 'pokeGroup': await this.executePokeGroup(data as PokeGroupActionData, ctx, client); break;
|
||||
case 'rankCheck': await this.executeRankCheck(data as RankCheckActionData, ctx, client); break;
|
||||
case 'tempChannelCleanup': await this.executeTempChannelCleanup(data as TempChannelCleanupActionData, ctx, client); break;
|
||||
case 'voicePlay': await this.executeVoicePlay(data, ctx); break;
|
||||
case 'voiceStop': await this.executeVoiceStop(data, ctx); break;
|
||||
case 'voiceJoinChannel': await this.executeVoiceJoinChannel(data, ctx); break;
|
||||
case 'voiceLeaveChannel': await this.executeVoiceLeaveChannel(data, ctx); break;
|
||||
case 'voiceVolume': await this.executeVoiceVolume(data, ctx); break;
|
||||
case 'voicePauseResume': await this.executeVoicePauseResume(data, ctx); break;
|
||||
case 'voiceSkip': await this.executeVoiceSkip(data, ctx); break;
|
||||
case 'voiceSeek': await this.executeVoiceSeek(data, ctx); break;
|
||||
case 'voiceTts': await this.executeVoiceTts(data, ctx); break;
|
||||
case 'animatedChannel':
|
||||
// Animation lifecycle managed by BotEngine (not per-execution)
|
||||
await this.log(ctx, node, 'info', 'Animation managed by engine');
|
||||
break;
|
||||
default:
|
||||
await this.log(ctx, node, 'warn', `Unknown action type: ${actionType}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this.log(ctx, node, 'error', `Action '${actionType}' failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeKick(data: KickActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const clid = ctx.eventData.clid;
|
||||
const reasonMsg = await ctx.resolveTemplate(data.reasonMsg || '');
|
||||
await client.executePost(ctx.sid, 'clientkick', {
|
||||
clid,
|
||||
reasonid: data.reasonId,
|
||||
reasonmsg: reasonMsg || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeBan(data: BanActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const clid = ctx.eventData.clid;
|
||||
const reason = data.reason ? await ctx.resolveTemplate(data.reason) : undefined;
|
||||
await client.executePost(ctx.sid, 'banclient', {
|
||||
clid,
|
||||
time: data.duration || 0,
|
||||
banreason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeMove(data: MoveActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const clid = ctx.eventData.clid;
|
||||
const cid = await ctx.resolveTemplate(data.channelId);
|
||||
await client.executePost(ctx.sid, 'clientmove', { clid, cid });
|
||||
}
|
||||
|
||||
private async executeMessage(data: MessageActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const msg = await ctx.resolveTemplate(data.message);
|
||||
const target = data.target ? await ctx.resolveTemplate(data.target) : ctx.eventData.clid;
|
||||
|
||||
// TS3 channel messages always go to the query client's current channel.
|
||||
// If a specific channel is targeted, move the query client there first, then move back.
|
||||
if (data.targetMode === 2 && target) {
|
||||
const whoami = await client.execute(ctx.sid, 'whoami');
|
||||
const myClid = whoami?.[0]?.client_id;
|
||||
const myCid = whoami?.[0]?.client_channel_id;
|
||||
if (myClid) {
|
||||
await client.executePost(ctx.sid, 'clientmove', { clid: myClid, cid: target });
|
||||
await client.executePost(ctx.sid, 'sendtextmessage', { targetmode: 2, msg });
|
||||
// Move back to original channel
|
||||
if (myCid && String(myCid) !== String(target)) {
|
||||
await client.executePost(ctx.sid, 'clientmove', { clid: myClid, cid: myCid });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.executePost(ctx.sid, 'sendtextmessage', {
|
||||
targetmode: data.targetMode,
|
||||
target,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
|
||||
private async executePoke(data: PokeActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const clid = ctx.eventData.clid;
|
||||
const msg = await ctx.resolveTemplate(data.message);
|
||||
await client.executePost(ctx.sid, 'clientpoke', { clid, msg });
|
||||
}
|
||||
|
||||
private async executeChannelCreate(data: ChannelCreateActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(data.params || {})) {
|
||||
resolved[key] = await ctx.resolveTemplate(val);
|
||||
}
|
||||
const result = await client.executePost(ctx.sid, 'channelcreate', resolved);
|
||||
if (result?.[0]?.cid) {
|
||||
ctx.setTemp('lastCreatedChannelId', result[0].cid);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeGroupAddClient(data: GroupAddClientActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const cldbid = ctx.eventData.client_database_id;
|
||||
const sgid = await ctx.resolveTemplate(data.groupId);
|
||||
await client.executePost(ctx.sid, 'servergroupaddclient', { sgid, cldbid });
|
||||
}
|
||||
|
||||
private async executeGroupRemoveClient(data: GroupRemoveClientActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const cldbid = ctx.eventData.client_database_id;
|
||||
const sgid = await ctx.resolveTemplate(data.groupId);
|
||||
await client.executePost(ctx.sid, 'servergroupdelclient', { sgid, cldbid });
|
||||
}
|
||||
|
||||
private async executeWebQuery(data: WebQueryActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(data.params || {})) {
|
||||
resolved[key] = await ctx.resolveTemplate(val);
|
||||
}
|
||||
|
||||
// Support inline params in command string: "clientinfo clid=5" → command="clientinfo", params={clid:"5"}
|
||||
let command = await ctx.resolveTemplate(data.command);
|
||||
const spaceIdx = command.indexOf(' ');
|
||||
if (spaceIdx > 0) {
|
||||
const inlinePart = command.substring(spaceIdx + 1);
|
||||
command = command.substring(0, spaceIdx);
|
||||
for (const pair of inlinePart.split(/\s+/)) {
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
const k = pair.substring(0, eqIdx);
|
||||
const v = pair.substring(eqIdx + 1);
|
||||
if (!resolved[k]) resolved[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.executePost(ctx.sid, command, resolved);
|
||||
ctx.setTemp('lastResult', JSON.stringify(result));
|
||||
if (data.storeAs && result?.[0]) {
|
||||
ctx.setTemp(data.storeAs, result[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeWebhook(data: WebhookActionData, ctx: ExecutionContext): Promise<void> {
|
||||
const url = await ctx.resolveTemplate(data.url);
|
||||
const body = data.body ? await ctx.resolveTemplate(data.body) : undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
if (data.headers) {
|
||||
for (const [k, v] of Object.entries(data.headers)) {
|
||||
headers[k] = await ctx.resolveTemplate(v);
|
||||
}
|
||||
}
|
||||
const response = await axios({
|
||||
method: (data.method || 'POST') as any,
|
||||
url,
|
||||
headers,
|
||||
data: body ? JSON.parse(body) : undefined,
|
||||
timeout: 10000,
|
||||
});
|
||||
if (data.storeAs) {
|
||||
ctx.setTemp(data.storeAs, response.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeChannelEdit(data: ChannelEditActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const cid = await ctx.resolveTemplate(data.channelId);
|
||||
const resolved: Record<string, string> = { cid };
|
||||
for (const [key, val] of Object.entries(data.params || {})) {
|
||||
resolved[key] = await ctx.resolveTemplate(val);
|
||||
}
|
||||
await client.executePost(ctx.sid, 'channeledit', resolved);
|
||||
}
|
||||
|
||||
private async executeChannelDelete(data: ChannelDeleteActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const cid = await ctx.resolveTemplate(data.channelId);
|
||||
await client.executePost(ctx.sid, 'channeldelete', { cid, force: data.force ? 1 : 0 });
|
||||
}
|
||||
|
||||
private async executeHttpRequest(data: HttpRequestActionData, ctx: ExecutionContext): Promise<void> {
|
||||
const url = await ctx.resolveTemplate(data.url);
|
||||
const body = data.body ? await ctx.resolveTemplate(data.body) : undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
if (data.headers) {
|
||||
for (const [k, v] of Object.entries(data.headers)) {
|
||||
headers[k] = await ctx.resolveTemplate(v);
|
||||
}
|
||||
}
|
||||
const response = await axios({
|
||||
method: (data.method || 'GET') as any,
|
||||
url,
|
||||
headers,
|
||||
data: body ? JSON.parse(body) : undefined,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (data.storeAs) {
|
||||
ctx.setTemp(data.storeAs, response.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAfkMover(data: AfkMoverActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const afkCid = await ctx.resolveTemplate(data.afkChannelId);
|
||||
const thresholdSec = data.idleThresholdSeconds || 300;
|
||||
const exemptIds = data.exemptGroupIds
|
||||
? (await ctx.resolveTemplate(data.exemptGroupIds)).split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const clients = await client.executePost(ctx.sid, 'clientlist', { '-times': '', '-groups': '' });
|
||||
if (!Array.isArray(clients)) return;
|
||||
|
||||
let movedCount = 0;
|
||||
for (const cl of clients) {
|
||||
// Skip query clients
|
||||
if (String(cl.client_type) === '1') continue;
|
||||
// Skip clients already in the AFK channel
|
||||
if (String(cl.cid) === String(afkCid)) continue;
|
||||
// Check idle time
|
||||
const idleMs = parseInt(cl.client_idle_time) || 0;
|
||||
if (idleMs / 1000 < thresholdSec) continue;
|
||||
// Check exempt groups
|
||||
if (exemptIds.length > 0 && cl.client_servergroups) {
|
||||
const clientGroups = String(cl.client_servergroups).split(',');
|
||||
if (exemptIds.some(g => clientGroups.includes(g))) continue;
|
||||
}
|
||||
try {
|
||||
await client.executePost(ctx.sid, 'clientmove', { clid: cl.clid, cid: afkCid });
|
||||
movedCount++;
|
||||
} catch { /* skip clients that can't be moved */ }
|
||||
}
|
||||
ctx.setTemp('afkMovedCount', movedCount);
|
||||
}
|
||||
|
||||
private async executeIdleKicker(data: IdleKickerActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const thresholdSec = data.idleThresholdSeconds || 1800;
|
||||
const reason = data.reason ? await ctx.resolveTemplate(data.reason) : 'Idle timeout';
|
||||
const exemptIds = data.exemptGroupIds
|
||||
? (await ctx.resolveTemplate(data.exemptGroupIds)).split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const clients = await client.executePost(ctx.sid, 'clientlist', { '-times': '', '-groups': '' });
|
||||
if (!Array.isArray(clients)) return;
|
||||
|
||||
let kickedCount = 0;
|
||||
for (const cl of clients) {
|
||||
if (String(cl.client_type) === '1') continue;
|
||||
const idleMs = parseInt(cl.client_idle_time) || 0;
|
||||
if (idleMs / 1000 < thresholdSec) continue;
|
||||
if (exemptIds.length > 0 && cl.client_servergroups) {
|
||||
const clientGroups = String(cl.client_servergroups).split(',');
|
||||
if (exemptIds.some(g => clientGroups.includes(g))) continue;
|
||||
}
|
||||
try {
|
||||
await client.executePost(ctx.sid, 'clientkick', { clid: cl.clid, reasonid: 5, reasonmsg: reason });
|
||||
kickedCount++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
ctx.setTemp('idleKickedCount', kickedCount);
|
||||
}
|
||||
|
||||
private async executePokeGroup(data: PokeGroupActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const sgid = await ctx.resolveTemplate(data.groupId);
|
||||
const msg = await ctx.resolveTemplate(data.message);
|
||||
|
||||
// Get all members of the group
|
||||
const groupClients = await client.executePost(ctx.sid, 'servergroupclientlist', { sgid });
|
||||
if (!Array.isArray(groupClients) || groupClients.length === 0) return;
|
||||
const cldbids = new Set(groupClients.map((gc: any) => String(gc.cldbid)));
|
||||
|
||||
// Get online clients to map cldbid → clid
|
||||
const onlineClients = await client.executePost(ctx.sid, 'clientlist', {});
|
||||
if (!Array.isArray(onlineClients)) return;
|
||||
|
||||
let pokedCount = 0;
|
||||
for (const cl of onlineClients) {
|
||||
if (String(cl.client_type) === '1') continue;
|
||||
if (!cldbids.has(String(cl.client_database_id))) continue;
|
||||
try {
|
||||
await client.executePost(ctx.sid, 'clientpoke', { clid: cl.clid, msg });
|
||||
pokedCount++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
ctx.setTemp('pokedCount', pokedCount);
|
||||
}
|
||||
|
||||
private async executeRankCheck(data: RankCheckActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
// ranks is JSON: [{ "hours": 10, "groupId": "7" }, { "hours": 50, "groupId": "8" }]
|
||||
let ranks: Array<{ hours: number; groupId: string }>;
|
||||
try {
|
||||
ranks = JSON.parse(await ctx.resolveTemplate(data.ranks));
|
||||
} catch {
|
||||
throw new Error('Invalid ranks JSON in rankCheck action');
|
||||
}
|
||||
if (!Array.isArray(ranks) || ranks.length === 0) return;
|
||||
|
||||
// Sort ranks descending by hours so highest rank is checked first
|
||||
ranks.sort((a, b) => b.hours - a.hours);
|
||||
|
||||
const clients = await client.executePost(ctx.sid, 'clientlist', { '-times': '', '-groups': '' });
|
||||
if (!Array.isArray(clients)) return;
|
||||
|
||||
let promoted = 0;
|
||||
for (const cl of clients) {
|
||||
if (String(cl.client_type) === '1') continue;
|
||||
const cldbid = String(cl.client_database_id);
|
||||
|
||||
// Get total online time from BotVariable (accumulate via cron)
|
||||
const varName = `onlinetime_${cldbid}`;
|
||||
const storedStr = await ctx.getVariable(varName);
|
||||
const storedSeconds = parseFloat(storedStr) || 0;
|
||||
// Add current connection time
|
||||
const connectionTime = parseInt(cl.connection_connected_time) || 0;
|
||||
const totalSeconds = storedSeconds + connectionTime / 1000;
|
||||
const totalHours = totalSeconds / 3600;
|
||||
|
||||
const clientGroups = String(cl.client_servergroups || '').split(',');
|
||||
|
||||
for (const rank of ranks) {
|
||||
if (totalHours >= rank.hours && !clientGroups.includes(rank.groupId)) {
|
||||
try {
|
||||
await client.executePost(ctx.sid, 'servergroupaddclient', { sgid: rank.groupId, cldbid });
|
||||
promoted++;
|
||||
} catch { /* skip */ }
|
||||
break; // Only assign highest eligible rank
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.setTemp('rankPromotedCount', promoted);
|
||||
}
|
||||
|
||||
private async executeTempChannelCleanup(data: TempChannelCleanupActionData, ctx: ExecutionContext, client: WebQueryClient): Promise<void> {
|
||||
const parentCid = await ctx.resolveTemplate(data.parentChannelId);
|
||||
const protectedStr = data.protectedChannelIds ? await ctx.resolveTemplate(data.protectedChannelIds) : '';
|
||||
const protectedSet = new Set(protectedStr.split(',').map(s => s.trim()).filter(Boolean));
|
||||
|
||||
const channels = await client.execute(ctx.sid, 'channellist');
|
||||
if (!Array.isArray(channels)) return;
|
||||
|
||||
let deleted = 0;
|
||||
for (const ch of channels) {
|
||||
const cid = String(ch.cid);
|
||||
const pid = String(ch.pid);
|
||||
const totalClients = parseInt(ch.total_clients) || 0;
|
||||
|
||||
if (pid !== parentCid) continue;
|
||||
if (protectedSet.has(cid)) continue;
|
||||
if (totalClients > 0) continue;
|
||||
|
||||
try {
|
||||
await client.executePost(ctx.sid, 'channeldelete', { cid, force: 1 });
|
||||
deleted++;
|
||||
} catch {
|
||||
// Channel might have clients that joined between list and delete — ignore
|
||||
}
|
||||
}
|
||||
ctx.setTemp('tempChannelsDeleted', deleted);
|
||||
}
|
||||
|
||||
// --- Voice Action Methods ---
|
||||
|
||||
private getVoiceBotManager(): VoiceBotManager {
|
||||
if (!this.voiceBotManager) throw new Error('VoiceBotManager not initialized');
|
||||
return this.voiceBotManager;
|
||||
}
|
||||
|
||||
private async executeVoicePlay(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
if (!botId) throw new Error('Voice Play: botId is required');
|
||||
|
||||
if (data.playlistId) {
|
||||
const playlistId = parseInt(await ctx.resolveTemplate(data.playlistId));
|
||||
if (playlistId) {
|
||||
const playlist = await this.prisma.playlist.findUnique({
|
||||
where: { id: playlistId },
|
||||
include: { songs: { include: { song: true }, orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (playlist) {
|
||||
const bot = mgr.getBot(botId);
|
||||
if (bot) {
|
||||
for (const ps of playlist.songs) {
|
||||
bot.queue.add({
|
||||
id: `song_${ps.song.id}`,
|
||||
title: ps.song.title,
|
||||
artist: ps.song.artist || undefined,
|
||||
duration: ps.song.duration || undefined,
|
||||
filePath: ps.song.filePath,
|
||||
source: (ps.song.source as 'local' | 'youtube' | 'url') || 'local',
|
||||
sourceUrl: ps.song.sourceUrl || undefined,
|
||||
});
|
||||
}
|
||||
if (bot.status === 'connected') {
|
||||
const first = bot.queue.next();
|
||||
if (first) bot.play(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.songId) {
|
||||
const songId = parseInt(await ctx.resolveTemplate(data.songId));
|
||||
if (songId) {
|
||||
const song = await this.prisma.song.findUnique({ where: { id: songId } });
|
||||
if (song) {
|
||||
const bot = mgr.getBot(botId);
|
||||
if (bot) {
|
||||
bot.queue.add({
|
||||
id: `song_${song.id}`,
|
||||
title: song.title,
|
||||
artist: song.artist || undefined,
|
||||
duration: song.duration || undefined,
|
||||
filePath: song.filePath,
|
||||
source: (song.source as 'local' | 'youtube' | 'url') || 'local',
|
||||
sourceUrl: song.sourceUrl || undefined,
|
||||
});
|
||||
if (bot.status === 'connected') {
|
||||
const first = bot.queue.next();
|
||||
if (first) bot.play(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeVoiceStop(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
if (!botId) throw new Error('Voice Stop: botId is required');
|
||||
await mgr.stopBot(botId);
|
||||
}
|
||||
|
||||
private async executeVoiceJoinChannel(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
const channelId = await ctx.resolveTemplate(data.channelId);
|
||||
const channelPassword = data.channelPassword ? await ctx.resolveTemplate(data.channelPassword) : undefined;
|
||||
if (!botId) throw new Error('Voice Join: botId is required');
|
||||
// Reconnect bot with the specified channel as default
|
||||
await mgr.stopBot(botId);
|
||||
const bot = mgr.getBot(botId);
|
||||
if (bot) {
|
||||
// Update the default channel before restart
|
||||
await this.prisma.musicBot.update({ where: { id: botId }, data: { defaultChannel: channelId, channelPassword: channelPassword ?? null } });
|
||||
}
|
||||
await mgr.startBot(botId);
|
||||
}
|
||||
|
||||
private async executeVoiceLeaveChannel(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
if (!botId) throw new Error('Voice Leave: botId is required');
|
||||
await mgr.stopBot(botId);
|
||||
}
|
||||
|
||||
private async executeVoiceVolume(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
const volume = parseInt(await ctx.resolveTemplate(data.volume));
|
||||
if (!botId) throw new Error('Voice Volume: botId is required');
|
||||
const bot = mgr.getBot(botId);
|
||||
if (bot) bot.setVolume(Math.max(0, Math.min(100, volume || 50)));
|
||||
}
|
||||
|
||||
private async executeVoicePauseResume(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
if (!botId) throw new Error('Voice PauseResume: botId is required');
|
||||
const bot = mgr.getBot(botId);
|
||||
if (!bot) return;
|
||||
const action = data.action || 'toggle';
|
||||
if (action === 'pause') {
|
||||
bot.pause();
|
||||
} else if (action === 'resume') {
|
||||
bot.resume();
|
||||
} else {
|
||||
// toggle
|
||||
if (bot.status === 'paused') bot.resume(); else bot.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private async executeVoiceSkip(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
if (!botId) throw new Error('Voice Skip: botId is required');
|
||||
const bot = mgr.getBot(botId);
|
||||
if (!bot) return;
|
||||
if (data.direction === 'previous') {
|
||||
bot.previous();
|
||||
} else {
|
||||
bot.skip();
|
||||
}
|
||||
}
|
||||
|
||||
private async executeVoiceSeek(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
const mgr = this.getVoiceBotManager();
|
||||
const botId = parseInt(await ctx.resolveTemplate(data.botId));
|
||||
const position = parseInt(await ctx.resolveTemplate(data.position));
|
||||
if (!botId) throw new Error('Voice Seek: botId is required');
|
||||
const bot = mgr.getBot(botId);
|
||||
if (bot) bot.seek(position || 0);
|
||||
}
|
||||
|
||||
private async executeVoiceTts(data: any, ctx: ExecutionContext): Promise<void> {
|
||||
// TTS is a placeholder — log that it's not yet implemented
|
||||
const text = await ctx.resolveTemplate(data.text || '');
|
||||
console.log(`[FlowRunner] TTS not yet implemented. Text: "${text}", language: ${data.language || 'default'}`);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private getOutgoingEdges(nodeId: string, flowDef: FlowDefinition, handle?: string): FlowEdge[] {
|
||||
return flowDef.edges.filter(e => {
|
||||
if (e.source !== nodeId) return false;
|
||||
if (handle !== undefined) {
|
||||
return e.sourceHandle === handle;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private findNode(nodeId: string, flowDef: FlowDefinition): FlowNode | undefined {
|
||||
return flowDef.nodes.find(n => n.id === nodeId);
|
||||
}
|
||||
|
||||
private async log(
|
||||
ctx: ExecutionContext,
|
||||
node: FlowNode | null,
|
||||
level: string,
|
||||
message: string,
|
||||
data?: any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.prisma.botExecutionLog.create({
|
||||
data: {
|
||||
executionId: ctx.executionId,
|
||||
serverConfigId: ctx.configId,
|
||||
flowId: ctx.flowId,
|
||||
nodeId: node?.id || null,
|
||||
nodeName: node?.data?.label || null,
|
||||
level,
|
||||
message,
|
||||
data: data ? JSON.stringify(data) : null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Swallow DB errors during logging to avoid cascading failures
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(type: string, payload: any): void {
|
||||
const msg = JSON.stringify({ type, ...payload });
|
||||
this.wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { Client as SSH2Client, type ClientChannel } from 'ssh2';
|
||||
import { EventEmitter } from 'events';
|
||||
import { parseQueryResponse } from '@ts6/common';
|
||||
import { TS_EVENT_TYPES } from '@ts6/common';
|
||||
|
||||
export interface SshQueryClientOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface QueuedCommand {
|
||||
command: string;
|
||||
resolve: (result: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
responseLines: string[];
|
||||
}
|
||||
|
||||
export declare interface SshQueryClient {
|
||||
on(event: 'ready', listener: () => void): this;
|
||||
on(event: 'event', listener: (eventName: string, data: Record<string, string>) => void): this;
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
emit(event: 'ready'): boolean;
|
||||
emit(event: 'event', eventName: string, data: Record<string, string>): boolean;
|
||||
emit(event: 'error', err: Error): boolean;
|
||||
emit(event: 'close'): boolean;
|
||||
}
|
||||
|
||||
export class SshQueryClient extends EventEmitter {
|
||||
private ssh: SSH2Client | null = null;
|
||||
private shell: ClientChannel | null = null;
|
||||
private commandQueue: QueuedCommand[] = [];
|
||||
private currentCommand: QueuedCommand | null = null;
|
||||
private responseBuffer: string = '';
|
||||
private connected: boolean = false;
|
||||
private destroyed: boolean = false;
|
||||
private bannerReceived: boolean = false;
|
||||
private reconnectAttempt: number = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private fatalError: boolean = false;
|
||||
|
||||
constructor(private options: SshQueryClientOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.destroyed) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.ssh = new SSH2Client();
|
||||
let settled = false;
|
||||
|
||||
// Timeout for the entire connect+banner sequence
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
const err = new Error(`SSH connect timeout for ${this.options.host}:${this.options.port}`);
|
||||
console.error(`[SshQueryClient] ${err.message}`);
|
||||
this.emit('error', err);
|
||||
reject(err);
|
||||
try { this.ssh?.end(); } catch {}
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
this.ssh.on('ready', () => {
|
||||
this.ssh!.shell(false, (err, channel) => {
|
||||
if (err) {
|
||||
if (!settled) { settled = true; clearTimeout(connectTimeout); reject(err); }
|
||||
this.emit('error', err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.shell = channel;
|
||||
this.responseBuffer = '';
|
||||
this.bannerReceived = false;
|
||||
|
||||
channel.on('data', (data: Buffer) => {
|
||||
this.onShellData(data);
|
||||
// Check if banner has been received after processing data
|
||||
if (!this.connected && this.bannerReceived) {
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
this.startKeepalive();
|
||||
this.emit('ready');
|
||||
if (!settled) { settled = true; clearTimeout(connectTimeout); resolve(); }
|
||||
}
|
||||
});
|
||||
|
||||
channel.on('close', () => {
|
||||
this.connected = false;
|
||||
this.bannerReceived = false;
|
||||
this.rejectAllPending('SSH channel closed');
|
||||
if (!this.destroyed) {
|
||||
this.emit('close');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
channel.stderr.on('data', (data: Buffer) => {
|
||||
console.error(`[SshQueryClient] stderr: ${data.toString('utf-8')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.ssh.on('error', (err: Error) => {
|
||||
const isAuthError = err.message.includes('authentication') || err.message.includes('Auth');
|
||||
if (isAuthError) {
|
||||
this.fatalError = true;
|
||||
console.error(`[SshQueryClient] Fatal auth error for ${this.options.host}:${this.options.port}: ${err.message}`);
|
||||
}
|
||||
this.emit('error', err);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(connectTimeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.ssh.on('close', () => {
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
this.bannerReceived = false;
|
||||
this.stopKeepalive();
|
||||
this.rejectAllPending('SSH connection closed');
|
||||
if (!this.destroyed && wasConnected) {
|
||||
this.emit('close');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ssh.connect({
|
||||
host: this.options.host,
|
||||
port: this.options.port,
|
||||
username: this.options.username,
|
||||
password: this.options.password,
|
||||
keepaliveInterval: 0, // We handle keepalive ourselves
|
||||
readyTimeout: 10000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async executeCommand(command: string, timeoutMs: number = 10000): Promise<string> {
|
||||
if (!this.connected || !this.shell) {
|
||||
throw new Error('SSH not connected');
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.currentCommand === entry) {
|
||||
this.currentCommand = null;
|
||||
this.processQueue();
|
||||
} else {
|
||||
const idx = this.commandQueue.indexOf(entry);
|
||||
if (idx !== -1) this.commandQueue.splice(idx, 1);
|
||||
}
|
||||
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const entry: QueuedCommand = {
|
||||
command,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
responseLines: [],
|
||||
};
|
||||
|
||||
this.commandQueue.push(entry);
|
||||
if (!this.currentCommand) {
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async registerEvents(sid: number): Promise<void> {
|
||||
console.log(`[SshQueryClient] Registering events for sid=${sid} on ${this.options.host}`);
|
||||
await this.executeCommand(`use sid=${sid}`);
|
||||
|
||||
// Set nickname so the bot is identifiable, and mark as query client type
|
||||
try {
|
||||
await this.executeCommand('clientupdate client_nickname=TS6-WebUI-Bot');
|
||||
} catch {
|
||||
// Nickname might already be taken; not critical
|
||||
}
|
||||
|
||||
for (const eventType of TS_EVENT_TYPES) {
|
||||
const cmd = eventType === 'channel'
|
||||
? `servernotifyregister event=${eventType} id=0`
|
||||
: `servernotifyregister event=${eventType}`;
|
||||
try {
|
||||
await this.executeCommand(cmd);
|
||||
} catch (err: any) {
|
||||
// error id=516 = already registered, ignore
|
||||
if (!err.message?.includes('516')) {
|
||||
console.warn(`[SshQueryClient] Failed to register event ${eventType}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SshQueryClient] Events registered for sid=${sid}`);
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
get hasFatalError(): boolean {
|
||||
return this.fatalError;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.stopKeepalive();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.rejectAllPending('Client destroyed');
|
||||
if (this.shell) {
|
||||
this.shell.close();
|
||||
this.shell = null;
|
||||
}
|
||||
if (this.ssh) {
|
||||
this.ssh.end();
|
||||
this.ssh = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
// --- Internals ---
|
||||
|
||||
private onShellData(data: Buffer): void {
|
||||
this.responseBuffer += data.toString('utf-8');
|
||||
const lines = this.responseBuffer.split(/\r?\n/);
|
||||
// Keep the last (possibly incomplete) line in the buffer
|
||||
this.responseBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Wait for banner before processing commands
|
||||
if (!this.bannerReceived) {
|
||||
// TS SSH banner: "TS3" followed by "Welcome to the TeamSpeak ServerQuery interface..."
|
||||
// Some versions also send "virtualserver_status=..." + "error id=0 msg=ok" after Welcome.
|
||||
// We mark the banner as complete once we see the Welcome line, since commands can be
|
||||
// sent immediately after it. Any trailing status/error lines are consumed below.
|
||||
if (trimmed === 'TS3' || trimmed.includes('TS3 Client')) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('Welcome')) {
|
||||
this.bannerReceived = true;
|
||||
continue;
|
||||
}
|
||||
// Consume any other banner lines (e.g., virtualserver_status=, error id=0)
|
||||
if (trimmed.startsWith('virtualserver_status=') || trimmed.startsWith('error id=0')) {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Notify events start with "notify"
|
||||
if (trimmed.startsWith('notify')) {
|
||||
this.handleNotifyLine(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Error line terminates a command response
|
||||
if (trimmed.startsWith('error ')) {
|
||||
this.handleErrorLine(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular response data line — accumulate for current command
|
||||
if (this.currentCommand) {
|
||||
this.currentCommand.responseLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotifyLine(line: string): void {
|
||||
// Extract event name (everything before first space)
|
||||
const spaceIdx = line.indexOf(' ');
|
||||
if (spaceIdx === -1) {
|
||||
this.emit('event', line, {});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = line.substring(0, spaceIdx);
|
||||
const dataStr = line.substring(spaceIdx + 1);
|
||||
|
||||
try {
|
||||
const entries = parseQueryResponse(dataStr);
|
||||
// Emit once per pipe-separated entry (usually just one for events)
|
||||
for (const entry of entries) {
|
||||
this.emit('event', eventName, entry);
|
||||
}
|
||||
} catch {
|
||||
this.emit('event', eventName, {});
|
||||
}
|
||||
}
|
||||
|
||||
private handleErrorLine(line: string): void {
|
||||
if (!this.currentCommand) return;
|
||||
|
||||
const cmd = this.currentCommand;
|
||||
clearTimeout(cmd.timeout);
|
||||
this.currentCommand = null;
|
||||
|
||||
// Parse "error id=N msg=..."
|
||||
const parsed = parseQueryResponse(line.substring(6))[0] || {};
|
||||
const errorId = parseInt(parsed.id || '0');
|
||||
|
||||
if (errorId === 0) {
|
||||
// Success
|
||||
cmd.resolve(cmd.responseLines.join('\n'));
|
||||
} else {
|
||||
cmd.reject(new Error(`TS error ${errorId}: ${parsed.msg || 'Unknown error'}`));
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
if (this.currentCommand || this.commandQueue.length === 0) return;
|
||||
if (!this.shell || !this.connected) return;
|
||||
|
||||
this.currentCommand = this.commandQueue.shift()!;
|
||||
this.currentCommand.responseLines = [];
|
||||
this.shell.write(this.currentCommand.command + '\n');
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
this.stopKeepalive();
|
||||
this.keepaliveTimer = setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.executeCommand('whoami', 5000).catch(() => {
|
||||
// Keepalive failure will trigger disconnect detection
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
private stopKeepalive(): void {
|
||||
if (this.keepaliveTimer) {
|
||||
clearInterval(this.keepaliveTimer);
|
||||
this.keepaliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.destroyed || this.fatalError) return;
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000);
|
||||
console.log(`[SshQueryClient] Reconnecting to ${this.options.host}:${this.options.port} in ${delay}ms (attempt ${this.reconnectAttempt + 1})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectAttempt++;
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (err: any) {
|
||||
console.error(`[SshQueryClient] Reconnect failed: ${err.message}`);
|
||||
// connect() failure will trigger another reconnect via the error/close handlers
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
if (this.currentCommand) {
|
||||
clearTimeout(this.currentCommand.timeout);
|
||||
this.currentCommand.reject(new Error(reason));
|
||||
this.currentCommand = null;
|
||||
}
|
||||
for (const cmd of this.commandQueue) {
|
||||
clearTimeout(cmd.timeout);
|
||||
cmd.reject(new Error(reason));
|
||||
}
|
||||
this.commandQueue = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
databaseUrl: process.env.DATABASE_URL || 'file:./data/ts6webui.db',
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-me-in-production',
|
||||
jwtAccessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
|
||||
jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createApp } from './app.js';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { PrismaClient } from '../generated/prisma/index.js';
|
||||
import { ConnectionPool } from './ts-client/connection-pool.js';
|
||||
import { BotEngine } from './bot-engine/engine.js';
|
||||
import { VoiceBotManager } from './voice/voice-bot-manager.js';
|
||||
import { MusicCommandHandler } from './voice/music-command-handler.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const app = createApp();
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket for real-time frontend updates
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
// Initialize TS connection pool
|
||||
const connectionPool = new ConnectionPool(prisma);
|
||||
await connectionPool.initialize();
|
||||
|
||||
// Make services available via app.locals
|
||||
app.locals.prisma = prisma;
|
||||
app.locals.connectionPool = connectionPool;
|
||||
app.locals.wss = wss;
|
||||
|
||||
// Initialize Bot Engine
|
||||
const botEngine = new BotEngine(prisma, connectionPool, wss, app);
|
||||
app.locals.botEngine = botEngine;
|
||||
await botEngine.start();
|
||||
|
||||
// Initialize Voice Bot Manager (Music Bots)
|
||||
const voiceBotManager = new VoiceBotManager(prisma, wss);
|
||||
app.locals.voiceBotManager = voiceBotManager;
|
||||
await voiceBotManager.start();
|
||||
|
||||
// Wire VoiceBotManager into BotEngine for voice action nodes in flows
|
||||
botEngine.setVoiceBotManager(voiceBotManager);
|
||||
|
||||
// Wire Music Command Handler for text-based music bot control (!radio, !play, etc.)
|
||||
// Listens directly on each VoiceBot's TS3 connection (no SSH needed)
|
||||
const musicCommandHandler = new MusicCommandHandler(prisma, voiceBotManager);
|
||||
voiceBotManager.setMusicCommandHandler(musicCommandHandler);
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`[TS6 WebUI] Backend running on http://localhost:${config.port}`);
|
||||
console.log(`[TS6 WebUI] WebSocket available at ws://localhost:${config.port}/ws`);
|
||||
console.log(`[TS6 WebUI] Environment: ${config.nodeEnv}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('\n[TS6 WebUI] Shutting down...');
|
||||
await voiceBotManager.stopAll();
|
||||
botEngine.destroy();
|
||||
connectionPool.destroy();
|
||||
wss.close();
|
||||
server.close();
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config.js';
|
||||
import type { JwtPayload } from '@ts6/common';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TSApiError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TSApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
|
||||
console.error(`[Error] ${err.name}: ${err.message}`);
|
||||
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.statusCode).json({
|
||||
error: err.message,
|
||||
details: err.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof TSApiError) {
|
||||
res.status(502).json({
|
||||
error: 'TeamSpeak API Error',
|
||||
code: err.code,
|
||||
details: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { UserRole } from '@ts6/common';
|
||||
|
||||
export function requireRole(...roles: UserRole[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
if (!roles.includes(req.user.role)) {
|
||||
res.status(403).json({ error: 'Insufficient permissions' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { config } from '../config.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
|
||||
export const authRoutes: Router = Router();
|
||||
|
||||
authRoutes.post('/login', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) throw new AppError(400, 'Username and password required');
|
||||
|
||||
const prisma = req.app.locals.prisma;
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
|
||||
if (!user || !user.enabled) throw new AppError(401, 'Invalid credentials');
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) throw new AppError(401, 'Invalid credentials');
|
||||
|
||||
const payload = { id: user.id, username: user.username, role: user.role };
|
||||
const accessToken = jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtAccessExpiry } as jwt.SignOptions);
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, userId: user.id, expiresAt },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
authRoutes.post('/refresh', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) throw new AppError(400, 'Refresh token required');
|
||||
|
||||
const prisma = req.app.locals.prisma;
|
||||
const stored = await prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date() || !stored.user.enabled) {
|
||||
if (stored) await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
throw new AppError(401, 'Invalid refresh token');
|
||||
}
|
||||
|
||||
// Rotate refresh token
|
||||
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
const newRefreshToken = crypto.randomBytes(64).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: newRefreshToken, userId: stored.userId, expiresAt },
|
||||
});
|
||||
|
||||
const payload = { id: stored.user.id, username: stored.user.username, role: stored.user.role };
|
||||
const accessToken = jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtAccessExpiry } as jwt.SignOptions);
|
||||
|
||||
res.json({ accessToken, refreshToken: newRefreshToken });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
authRoutes.post('/logout', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
const prisma = req.app.locals.prisma;
|
||||
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
authRoutes.get('/me', authMiddleware, async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
authRoutes.put('/password', authMiddleware, async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) throw new AppError(400, 'Both passwords required');
|
||||
|
||||
const prisma = req.app.locals.prisma;
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) throw new AppError(401, 'Current password is incorrect');
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const banRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
banRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'banlist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
banRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.status(201).json(await getClient(req).execute(getSid(req), 'banadd', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
banRoutes.delete('/:banid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'bandel', { banid: String(req.params.banid) })); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
banRoutes.delete('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'bandelall')); } catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
|
||||
export const botRoutes: Router = Router();
|
||||
|
||||
botRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const bots = await prisma.botFlow.findMany({
|
||||
include: { _count: { select: { executions: true } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
res.json(bots.map((b: any) => ({
|
||||
id: b.id, name: b.name, description: b.description,
|
||||
serverConfigId: b.serverConfigId, virtualServerId: b.virtualServerId,
|
||||
enabled: b.enabled, createdAt: b.createdAt, updatedAt: b.updatedAt,
|
||||
executionCount: b._count.executions,
|
||||
})));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.get('/:botId', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const bot = await prisma.botFlow.findUnique({ where: { id: parseInt(String(req.params.botId)) } });
|
||||
if (!bot) throw new AppError(404, 'Bot flow not found');
|
||||
res.json({
|
||||
...bot,
|
||||
flowData: JSON.parse(bot.flowData),
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const { name, description, serverConfigId, virtualServerId, flowData } = req.body;
|
||||
|
||||
const parsedConfigId = parseInt(serverConfigId);
|
||||
if (!name || isNaN(parsedConfigId)) {
|
||||
return res.status(400).json({ error: 'Name and valid serverConfigId are required' });
|
||||
}
|
||||
|
||||
// Verify server config exists
|
||||
const serverConfig = await prisma.tsServerConfig.findUnique({ where: { id: parsedConfigId } });
|
||||
if (!serverConfig) {
|
||||
return res.status(400).json({ error: `Server config ${parsedConfigId} does not exist` });
|
||||
}
|
||||
|
||||
const bot = await prisma.botFlow.create({
|
||||
data: {
|
||||
name, description,
|
||||
serverConfigId: parsedConfigId,
|
||||
virtualServerId: parseInt(virtualServerId) || 1,
|
||||
flowData: flowData ? JSON.stringify(flowData) : '{"nodes":[],"edges":[]}',
|
||||
},
|
||||
});
|
||||
res.status(201).json({ id: bot.id, name: bot.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.put('/:botId', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const data: any = {};
|
||||
if (req.body.name !== undefined) data.name = req.body.name;
|
||||
if (req.body.description !== undefined) data.description = req.body.description;
|
||||
if (req.body.flowData !== undefined) data.flowData = JSON.stringify(req.body.flowData);
|
||||
if (req.body.serverConfigId !== undefined) data.serverConfigId = parseInt(req.body.serverConfigId);
|
||||
if (req.body.virtualServerId !== undefined) data.virtualServerId = parseInt(req.body.virtualServerId);
|
||||
|
||||
const botId = parseInt(String(req.params.botId));
|
||||
const bot = await prisma.botFlow.update({
|
||||
where: { id: botId },
|
||||
data,
|
||||
});
|
||||
|
||||
// Notify bot engine of flow update
|
||||
const botEngine = req.app.locals.botEngine;
|
||||
if (botEngine) await botEngine.reloadFlow(botId);
|
||||
|
||||
res.json({ id: bot.id, name: bot.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.delete('/:botId', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const botId = parseInt(String(req.params.botId));
|
||||
|
||||
// Disable in engine before deleting
|
||||
const botEngine = req.app.locals.botEngine;
|
||||
if (botEngine) await botEngine.disableFlow(botId);
|
||||
|
||||
await prisma.botFlow.delete({ where: { id: botId } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.post('/:botId/enable', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const botId = parseInt(String(req.params.botId));
|
||||
await prisma.botFlow.update({ where: { id: botId }, data: { enabled: true } });
|
||||
|
||||
// Enable in bot engine
|
||||
const botEngine = req.app.locals.botEngine;
|
||||
if (botEngine) await botEngine.enableFlow(botId);
|
||||
|
||||
res.json({ enabled: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.post('/:botId/disable', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const botId = parseInt(String(req.params.botId));
|
||||
await prisma.botFlow.update({ where: { id: botId }, data: { enabled: false } });
|
||||
|
||||
// Disable in bot engine
|
||||
const botEngine = req.app.locals.botEngine;
|
||||
if (botEngine) await botEngine.disableFlow(botId);
|
||||
|
||||
res.json({ enabled: false });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.get('/:botId/executions', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const executions = await prisma.botExecution.findMany({
|
||||
where: { flowId: parseInt(String(req.params.botId)) },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
res.json(executions);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
botRoutes.get('/:botId/executions/:execId/logs', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const logs = await prisma.botExecutionLog.findMany({
|
||||
where: { executionId: parseInt(String(req.params.execId)) },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
res.json(logs);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const channelGroupRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
channelGroupRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'channelgrouplist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.status(201).json(await getClient(req).execute(getSid(req), 'channelgroupadd', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.put('/:cgid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgrouprename', { cgid: String(req.params.cgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.delete('/:cgid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgroupdel', { cgid: String(req.params.cgid), force: 1 }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.get('/:cgid/clients', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgroupclientlist', { cgid: String(req.params.cgid) }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.post('/:cgid/assign', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'setclientchannelgroup', { cgid: String(req.params.cgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.get('/:cgid/permissions', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgrouppermlist', { cgid: String(req.params.cgid), '-permsid': '' }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.put('/:cgid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgroupaddperm', { cgid: String(req.params.cgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelGroupRoutes.delete('/:cgid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'channelgroupdelperm', { cgid: String(req.params.cgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const channelRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
channelRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channellist', {
|
||||
'-topic': '', '-flags': '', '-voice': '', '-limits': '', '-icon': '', '-secondsempty': '',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.get('/:cid', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channelinfo', { cid: String(req.params.cid) });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channelcreate', req.body);
|
||||
res.status(201).json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.put('/:cid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channeledit', { cid: String(req.params.cid), ...req.body });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.delete('/:cid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channeldelete', {
|
||||
cid: String(req.params.cid), force: req.query.force || 1,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.post('/:cid/move', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channelmove', {
|
||||
cid: String(req.params.cid), ...req.body,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.get('/:cid/permissions', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channelpermlist', {
|
||||
cid: String(req.params.cid), '-permsid': '',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.put('/:cid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channeladdperm', {
|
||||
cid: String(req.params.cid), ...req.body,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
channelRoutes.delete('/:cid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'channeldelperm', {
|
||||
cid: String(req.params.cid), ...req.body,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const clientRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
clientRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientlist', {
|
||||
'-uid': '', '-away': '', '-voice': '', '-times': '', '-groups': '', '-info': '', '-country': '', '-ip': '',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.get('/database', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientdblist', {
|
||||
start: req.query.start || 0, duration: req.query.duration || 100,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.get('/database/:cldbid', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientdbinfo', { cldbid: String(req.params.cldbid) });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.get('/:clid', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientinfo', { clid: String(req.params.clid) });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.post('/:clid/kick', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientkick', {
|
||||
clid: String(req.params.clid), reasonid: req.body.reasonid || 5, reasonmsg: req.body.reasonmsg,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.post('/:clid/ban', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'banclient', {
|
||||
clid: String(req.params.clid), time: req.body.time || 0, banreason: req.body.banreason,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.post('/:clid/move', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientmove', {
|
||||
clid: String(req.params.clid), cid: req.body.cid, cpw: req.body.cpw,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.post('/:clid/poke', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientpoke', {
|
||||
clid: String(req.params.clid), msg: req.body.msg,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.post('/:clid/message', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'sendtextmessage', {
|
||||
targetmode: 1, target: String(req.params.clid), msg: req.body.msg,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.get('/:clid/permissions', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'clientpermlist', {
|
||||
cldbid: String(req.params.clid), '-permsid': '',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
clientRoutes.get('/:clid/groups', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(getSid(req), 'servergroupsbyclientid', {
|
||||
cldbid: String(req.params.clid),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const complaintRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
complaintRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const params = req.query.tcldbid ? { tcldbid: req.query.tcldbid } : undefined;
|
||||
res.json(await getClient(req).execute(getSid(req), 'complainlist', params));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
complaintRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.status(201).json(await getClient(req).execute(getSid(req), 'complainadd', req.body));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
complaintRoutes.delete('/:tcldbid/:fcldbid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'complaindel', {
|
||||
tcldbid: String(req.params.tcldbid), fcldbid: String(req.params.fcldbid),
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const dashboardRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
|
||||
dashboardRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const client = getClient(req);
|
||||
|
||||
const [serverInfo, clientList, channelList, connectionInfo] = await Promise.all([
|
||||
client.execute(sid, 'serverinfo'),
|
||||
client.execute(sid, 'clientlist'),
|
||||
client.execute(sid, 'channellist'),
|
||||
client.execute(sid, 'serverrequestconnectioninfo'),
|
||||
]);
|
||||
|
||||
const info = Array.isArray(serverInfo) ? serverInfo[0] : serverInfo;
|
||||
const connInfo = Array.isArray(connectionInfo) ? connectionInfo[0] : connectionInfo;
|
||||
const clients = Array.isArray(clientList) ? clientList : [];
|
||||
const channels = Array.isArray(channelList) ? channelList : [];
|
||||
|
||||
const onlineClients = clients.filter((c: any) => String(c.client_type) === '0');
|
||||
|
||||
res.json({
|
||||
serverName: info.virtualserver_name,
|
||||
platform: info.virtualserver_platform,
|
||||
version: info.virtualserver_version,
|
||||
onlineUsers: onlineClients.length,
|
||||
maxClients: Number(info.virtualserver_maxclients) || 0,
|
||||
uptime: Number(info.virtualserver_uptime) || 0,
|
||||
channelCount: channels.length,
|
||||
bandwidth: {
|
||||
incoming: Number(connInfo.connection_bandwidth_received_last_second_total) || 0,
|
||||
outgoing: Number(connInfo.connection_bandwidth_sent_last_second_total) || 0,
|
||||
},
|
||||
packetloss: Number(info.virtualserver_total_packetloss_total) || 0,
|
||||
ping: Number(info.virtualserver_total_ping) || 0,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError, TSApiError } from '../middleware/error-handler.js';
|
||||
import { parseQueryResponse, tsEscape } from '@ts6/common';
|
||||
import type { BotEngine } from '../bot-engine/engine.js';
|
||||
|
||||
export const fileRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getConfigId = (req: Request) => parseInt(String(req.params.configId));
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
/**
|
||||
* Execute a ServerQuery command via the shared SSH connection (EventBridge).
|
||||
* Reuses the same SSH session used for bot events — no extra server slots.
|
||||
*/
|
||||
async function sshExecute(
|
||||
req: Request,
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const engine: BotEngine = req.app.locals.botEngine;
|
||||
if (!engine) throw new AppError(503, 'Bot engine not available');
|
||||
|
||||
const bridge = engine.getEventBridge();
|
||||
const configId = getConfigId(req);
|
||||
const sid = getSid(req);
|
||||
|
||||
// Build raw ServerQuery command string
|
||||
const paramStr = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${tsEscape(v)}`)
|
||||
.join(' ');
|
||||
const fullCommand = paramStr ? `${command} ${paramStr}` : command;
|
||||
|
||||
let rawResponse: string;
|
||||
try {
|
||||
rawResponse = await bridge.executeCommand(configId, sid, fullCommand);
|
||||
} catch (err: any) {
|
||||
// Convert "TS error {code}: {msg}" to TSApiError
|
||||
const match = err.message?.match(/^TS error (\d+): (.+)$/);
|
||||
if (match) {
|
||||
throw new TSApiError(parseInt(match[1]), match[2]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!rawResponse.trim()) return [];
|
||||
return parseQueryResponse(rawResponse);
|
||||
}
|
||||
|
||||
// List files in a channel directory
|
||||
// Uses shared SSH connection because ft* commands are not supported via WebQuery HTTP
|
||||
fileRoutes.get('/:cid', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await sshExecute(req, 'ftgetfilelist', {
|
||||
cid: String(req.params.cid),
|
||||
cpw: String(req.query.cpw || ''),
|
||||
path: String(req.query.path || '/'),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
// TS3 error 1281 = database_empty_result → empty directory
|
||||
if (err instanceof TSApiError && err.code === 1281) {
|
||||
return res.json([]);
|
||||
}
|
||||
if (err.message?.includes('SSH not connected') || err.message?.includes('SSH credentials')) {
|
||||
return next(new AppError(400, 'SSH credentials not configured for this server. File browsing requires SSH access because WebQuery HTTP does not support ft* commands.'));
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Create directory
|
||||
fileRoutes.post('/:cid/mkdir', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await sshExecute(req, 'ftcreatedir', {
|
||||
cid: String(req.params.cid),
|
||||
cpw: '',
|
||||
dirname: req.body.dirname,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete file
|
||||
fileRoutes.delete('/:cid/file', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await sshExecute(req, 'ftdeletefile', {
|
||||
cid: String(req.params.cid),
|
||||
cpw: '',
|
||||
name: req.body.name,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const instanceRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
|
||||
instanceRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(0, 'instanceinfo')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
instanceRoutes.put('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(0, 'instanceedit', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
instanceRoutes.get('/host', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(0, 'hostinfo')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
instanceRoutes.get('/version', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(0, 'version')); } catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const logRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
logRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'logview', {
|
||||
lines: req.query.lines || 100,
|
||||
reverse: req.query.reverse || 1,
|
||||
instance: req.query.instance || 0,
|
||||
begin_pos: req.query.begin_pos,
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const messageRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
messageRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'messagelist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
messageRoutes.get('/:msgid', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'messageget', { msgid: String(req.params.msgid) })); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
messageRoutes.post('/', async (req: Request, res: Response, next) => {
|
||||
try { res.status(201).json(await getClient(req).execute(getSid(req), 'messageadd', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
messageRoutes.delete('/:msgid', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'messagedel', { msgid: String(req.params.msgid) })); } catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { VoiceBotManager } from '../voice/voice-bot-manager.js';
|
||||
|
||||
export const musicBotRoutes: Router = Router();
|
||||
|
||||
// All routes require admin role
|
||||
musicBotRoutes.use(requireRole('admin'));
|
||||
|
||||
// GET / — List all music bots
|
||||
musicBotRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const dbBots = await prisma.musicBot.findMany({
|
||||
include: { serverConfig: { select: { id: true, name: true, host: true } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
const runtimeInfo = manager.listBots();
|
||||
const runtimeMap = new Map(runtimeInfo.map((b: any) => [b.id, b]));
|
||||
|
||||
res.json(dbBots.map((b: any) => {
|
||||
const runtime = runtimeMap.get(b.id);
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
serverConfigId: b.serverConfigId,
|
||||
serverConfig: b.serverConfig,
|
||||
nickname: b.nickname,
|
||||
defaultChannel: b.defaultChannel,
|
||||
volume: b.volume,
|
||||
autoStart: b.autoStart,
|
||||
status: runtime?.status ?? 'stopped',
|
||||
nowPlaying: runtime?.nowPlaying ?? null,
|
||||
createdAt: b.createdAt,
|
||||
};
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id — Get bot details + runtime status
|
||||
musicBotRoutes.get('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const dbBot = await prisma.musicBot.findUnique({
|
||||
where: { id },
|
||||
include: { serverConfig: { select: { id: true, name: true, host: true } } },
|
||||
});
|
||||
if (!dbBot) throw new AppError(404, 'Music bot not found');
|
||||
|
||||
const bot = manager.getBot(id);
|
||||
res.json({
|
||||
...dbBot,
|
||||
identityData: undefined, // don't expose identity
|
||||
status: bot?.status ?? 'stopped',
|
||||
nowPlaying: bot?.nowPlaying ?? null,
|
||||
playbackProgress: bot?.playbackProgress ?? null,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST / — Create bot
|
||||
musicBotRoutes.post('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const { name, serverConfigId, nickname, defaultChannel, channelPassword, volume, autoStart } = req.body;
|
||||
if (!name || !serverConfigId) throw new AppError(400, 'name and serverConfigId are required');
|
||||
|
||||
const result = await manager.createBot({
|
||||
name,
|
||||
serverConfigId: parseInt(serverConfigId),
|
||||
nickname,
|
||||
defaultChannel,
|
||||
channelPassword,
|
||||
volume: volume != null ? parseInt(volume) : undefined,
|
||||
autoStart: autoStart ?? false,
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /:id — Update bot config
|
||||
musicBotRoutes.put('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { name, nickname, defaultChannel, channelPassword, volume, autoStart } = req.body;
|
||||
|
||||
const dbBot = await prisma.musicBot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name != null && { name }),
|
||||
...(nickname != null && { nickname }),
|
||||
...(defaultChannel !== undefined && { defaultChannel }),
|
||||
...(channelPassword !== undefined && { channelPassword }),
|
||||
...(volume != null && { volume: parseInt(volume) }),
|
||||
...(autoStart != null && { autoStart }),
|
||||
},
|
||||
});
|
||||
|
||||
// Update runtime config if bot is loaded
|
||||
const bot = manager.getBot(id);
|
||||
if (bot) {
|
||||
bot.updateConfig({
|
||||
...(name != null && { name }),
|
||||
...(nickname != null && { nickname }),
|
||||
...(defaultChannel !== undefined && { defaultChannel: defaultChannel || undefined }),
|
||||
...(channelPassword !== undefined && { channelPassword: channelPassword || undefined }),
|
||||
...(volume != null && { volume: parseInt(volume) }),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id — Delete bot
|
||||
musicBotRoutes.delete('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
await manager.removeBot(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/start — Start bot
|
||||
musicBotRoutes.post('/:id/start', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
await manager.startBot(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/stop — Stop bot
|
||||
musicBotRoutes.post('/:id/stop', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
await manager.stopBot(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/restart — Restart bot
|
||||
musicBotRoutes.post('/:id/restart', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const bot = manager.getBot(id);
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
await bot.restart();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// === Playback Control ===
|
||||
|
||||
// POST /:id/play — Play a song
|
||||
musicBotRoutes.post('/:id/play', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { songId } = req.body;
|
||||
if (!songId) throw new AppError(400, 'songId is required');
|
||||
|
||||
const bot = manager.getBot(id);
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
if (bot.status !== 'connected' && bot.status !== 'playing' && bot.status !== 'paused') {
|
||||
throw new AppError(400, 'Bot is not connected');
|
||||
}
|
||||
|
||||
const song = await prisma.song.findUnique({ where: { id: parseInt(songId) } });
|
||||
if (!song) throw new AppError(404, 'Song not found');
|
||||
|
||||
const queueItem = {
|
||||
id: String(song.id),
|
||||
title: song.title,
|
||||
artist: song.artist ?? undefined,
|
||||
duration: song.duration ?? undefined,
|
||||
filePath: song.filePath,
|
||||
source: song.source as 'local' | 'youtube' | 'url',
|
||||
sourceUrl: song.sourceUrl ?? undefined,
|
||||
};
|
||||
|
||||
// Add to queue so repeat modes work, then play
|
||||
bot.queue.add(queueItem);
|
||||
bot.queue.playAt(bot.queue.length - 1);
|
||||
await bot.play(queueItem);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/play-radio — Play a radio station (streaming)
|
||||
musicBotRoutes.post('/:id/play-radio', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { stationId } = req.body;
|
||||
if (!stationId) throw new AppError(400, 'stationId is required');
|
||||
|
||||
const bot = manager.getBot(id);
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
if (bot.status !== 'connected' && bot.status !== 'playing' && bot.status !== 'paused') {
|
||||
throw new AppError(400, 'Bot is not connected');
|
||||
}
|
||||
|
||||
const station = await prisma.radioStation.findUnique({ where: { id: parseInt(stationId) } });
|
||||
if (!station) throw new AppError(404, 'Radio station not found');
|
||||
|
||||
const queueItem = {
|
||||
id: `radio_${station.id}`,
|
||||
title: station.name,
|
||||
artist: station.genre ?? 'Radio',
|
||||
filePath: '',
|
||||
source: 'radio' as const,
|
||||
streamUrl: station.url,
|
||||
};
|
||||
|
||||
await bot.playStream(queueItem);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/pause
|
||||
musicBotRoutes.post('/:id/pause', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.pause();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/resume
|
||||
musicBotRoutes.post('/:id/resume', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.resume();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/stop-playback
|
||||
musicBotRoutes.post('/:id/stop-playback', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.stopAudio();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/skip
|
||||
musicBotRoutes.post('/:id/skip', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.skip();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/previous
|
||||
musicBotRoutes.post('/:id/previous', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.previous();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/seek
|
||||
musicBotRoutes.post('/:id/seek', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
const { seconds } = req.body;
|
||||
bot.seek(parseFloat(seconds) || 0);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/volume
|
||||
musicBotRoutes.post('/:id/volume', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { volume } = req.body;
|
||||
const vol = Math.max(0, Math.min(100, parseInt(volume) || 50));
|
||||
|
||||
const bot = manager.getBot(id);
|
||||
if (bot) bot.setVolume(vol);
|
||||
await prisma.musicBot.update({ where: { id }, data: { volume: vol } });
|
||||
|
||||
res.json({ success: true, volume: vol });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/state — Full playback state
|
||||
musicBotRoutes.get('/:id/state', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
|
||||
const progress = bot.playbackProgress;
|
||||
res.json({
|
||||
status: bot.status,
|
||||
nowPlaying: bot.nowPlaying,
|
||||
position: progress?.position ?? 0,
|
||||
duration: progress?.duration ?? 0,
|
||||
volume: bot.currentConfig.volume,
|
||||
queue: bot.queue.getAll(),
|
||||
currentIndex: -1, // PlayQueue doesn't expose this directly
|
||||
shuffle: bot.queue.shuffle,
|
||||
repeat: bot.queue.repeat,
|
||||
isStreaming: bot.isStreaming,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// === Queue ===
|
||||
|
||||
// GET /:id/queue
|
||||
musicBotRoutes.get('/:id/queue', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
res.json({
|
||||
items: bot.queue.getAll(),
|
||||
shuffle: bot.queue.shuffle,
|
||||
repeat: bot.queue.repeat,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/queue — Enqueue a song
|
||||
musicBotRoutes.post('/:id/queue', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
|
||||
const { songId } = req.body;
|
||||
const song = await prisma.song.findUnique({ where: { id: parseInt(songId) } });
|
||||
if (!song) throw new AppError(404, 'Song not found');
|
||||
|
||||
bot.queue.add({
|
||||
id: String(song.id),
|
||||
title: song.title,
|
||||
artist: song.artist ?? undefined,
|
||||
duration: song.duration ?? undefined,
|
||||
filePath: song.filePath,
|
||||
source: song.source as any,
|
||||
sourceUrl: song.sourceUrl ?? undefined,
|
||||
});
|
||||
|
||||
res.json({ success: true, queueLength: bot.queue.length });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/queue/playlist — Load playlist into queue
|
||||
musicBotRoutes.post('/:id/queue/playlist', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
|
||||
const { playlistId, clearFirst } = req.body;
|
||||
const playlist = await prisma.playlist.findUnique({
|
||||
where: { id: parseInt(playlistId) },
|
||||
include: { songs: { include: { song: true }, orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!playlist) throw new AppError(404, 'Playlist not found');
|
||||
|
||||
if (clearFirst) bot.queue.clear();
|
||||
|
||||
const items = playlist.songs.map((ps: any) => ({
|
||||
id: String(ps.song.id),
|
||||
title: ps.song.title,
|
||||
artist: ps.song.artist ?? undefined,
|
||||
duration: ps.song.duration ?? undefined,
|
||||
filePath: ps.song.filePath,
|
||||
source: ps.song.source as any,
|
||||
sourceUrl: ps.song.sourceUrl ?? undefined,
|
||||
}));
|
||||
|
||||
bot.queue.addMany(items);
|
||||
res.json({ success: true, queueLength: bot.queue.length });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id/queue/:index — Remove from queue
|
||||
musicBotRoutes.delete('/:id/queue/:index', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
|
||||
const items = bot.queue.getAll();
|
||||
const index = parseInt(req.params.index as string);
|
||||
if (index >= 0 && index < items.length) {
|
||||
bot.queue.remove(items[index].id);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id/queue — Clear queue
|
||||
musicBotRoutes.delete('/:id/queue', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.queue.clear();
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/queue/shuffle
|
||||
musicBotRoutes.post('/:id/queue/shuffle', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
bot.queue.setShuffle(req.body.enabled ?? true);
|
||||
res.json({ success: true, shuffle: bot.queue.shuffle });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/queue/repeat
|
||||
musicBotRoutes.post('/:id/queue/repeat', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
|
||||
const bot = manager.getBot(parseInt(req.params.id as string));
|
||||
if (!bot) throw new AppError(404, 'Music bot not found');
|
||||
const mode = req.body.mode ?? 'off';
|
||||
if (!['off', 'track', 'queue'].includes(mode)) throw new AppError(400, 'Invalid repeat mode');
|
||||
bot.queue.setRepeat(mode);
|
||||
res.json({ success: true, repeat: bot.queue.repeat });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { downloadYouTube, searchYouTube, getYouTubeUrlInfo } from '../voice/audio/youtube.js';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const MUSIC_DIR = process.env.MUSIC_DIR || '/data/music';
|
||||
const ALLOWED_EXTENSIONS = ['.mp3', '.wav', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wma', '.webm'];
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
// Ensure music directory exists
|
||||
if (!fs.existsSync(MUSIC_DIR)) {
|
||||
fs.mkdirSync(MUSIC_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, MUSIC_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type: ${ext}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const musicLibraryRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
musicLibraryRoutes.use(requireRole('admin'));
|
||||
|
||||
// GET /songs — List songs for this server
|
||||
musicLibraryRoutes.get('/songs', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const songs = await prisma.song.findMany({
|
||||
where: { serverConfigId: configId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(songs);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /upload — Upload audio file
|
||||
musicLibraryRoutes.post('/upload', upload.single('file'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const file = req.file;
|
||||
if (!file) throw new AppError(400, 'No file uploaded');
|
||||
|
||||
// Extract duration via ffprobe
|
||||
let duration: number | null = null;
|
||||
try {
|
||||
duration = await getAudioDuration(file.path);
|
||||
} catch { /* ignore duration extraction failure */ }
|
||||
|
||||
// Parse title/artist from filename
|
||||
const baseName = path.basename(file.originalname, path.extname(file.originalname));
|
||||
let title = baseName;
|
||||
let artist: string | null = null;
|
||||
const dashIdx = baseName.indexOf(' - ');
|
||||
if (dashIdx > 0) {
|
||||
artist = baseName.substring(0, dashIdx).trim();
|
||||
title = baseName.substring(dashIdx + 3).trim();
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
title,
|
||||
artist,
|
||||
duration,
|
||||
filePath: file.path,
|
||||
source: 'local',
|
||||
fileSize: file.size,
|
||||
serverConfigId: configId,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(song);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /songs/:id — Delete song
|
||||
musicLibraryRoutes.delete('/songs/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const song = await prisma.song.findUnique({ where: { id } });
|
||||
if (!song) throw new AppError(404, 'Song not found');
|
||||
|
||||
// Remove file from disk
|
||||
try {
|
||||
if (fs.existsSync(song.filePath)) {
|
||||
fs.unlinkSync(song.filePath);
|
||||
}
|
||||
} catch { /* ignore file deletion failure */ }
|
||||
|
||||
await prisma.song.delete({ where: { id } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /youtube/search — Search YouTube
|
||||
musicLibraryRoutes.post('/youtube/search', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { query } = req.body;
|
||||
if (!query) throw new AppError(400, 'query is required');
|
||||
const results = await searchYouTube(query, 10);
|
||||
res.json(results);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /youtube/download — Download from YouTube
|
||||
musicLibraryRoutes.post('/youtube/download', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const { url } = req.body;
|
||||
if (!url) throw new AppError(400, 'url is required');
|
||||
|
||||
const { filePath, info } = await downloadYouTube(url, MUSIC_DIR);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
|
||||
// Check if song already exists for this server (by sourceUrl)
|
||||
const existing = await prisma.song.findFirst({
|
||||
where: { sourceUrl: url, serverConfigId: configId },
|
||||
});
|
||||
if (existing) {
|
||||
return res.json(existing);
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
duration: info.duration,
|
||||
filePath,
|
||||
source: 'youtube',
|
||||
sourceUrl: url,
|
||||
fileSize: fileStats.size,
|
||||
serverConfigId: configId,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(song);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /youtube/info — Get info about a YouTube URL (video or playlist)
|
||||
musicLibraryRoutes.post('/youtube/info', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) throw new AppError(400, 'url is required');
|
||||
const info = await getYouTubeUrlInfo(url);
|
||||
res.json(info);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /youtube/download-batch — Download multiple YouTube videos
|
||||
musicLibraryRoutes.post('/youtube/download-batch', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const { urls } = req.body;
|
||||
if (!Array.isArray(urls) || urls.length === 0) throw new AppError(400, 'urls array is required');
|
||||
|
||||
const results: any[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
// Check if already downloaded
|
||||
const existing = await prisma.song.findFirst({
|
||||
where: { sourceUrl: url, serverConfigId: configId },
|
||||
});
|
||||
if (existing) {
|
||||
results.push(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { filePath, info } = await downloadYouTube(url, MUSIC_DIR);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
duration: info.duration,
|
||||
filePath,
|
||||
source: 'youtube',
|
||||
sourceUrl: url,
|
||||
fileSize: fileStats.size,
|
||||
serverConfigId: configId,
|
||||
},
|
||||
});
|
||||
results.push(song);
|
||||
} catch (err: any) {
|
||||
errors.push(`${url}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results, errors, total: urls.length, downloaded: results.length });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Helper: get audio duration via ffprobe
|
||||
function getAudioDuration(filePath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('ffprobe', [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
filePath,
|
||||
], { shell: false });
|
||||
|
||||
let output = '';
|
||||
proc.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) return reject(new Error('ffprobe failed'));
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
resolve(parseFloat(parsed.format.duration) || 0);
|
||||
} catch {
|
||||
reject(new Error('Failed to parse ffprobe output'));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const permissionRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
permissionRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'permissionlist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
permissionRoutes.get('/find', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (req.query.permid) params.permid = req.query.permid;
|
||||
if (req.query.permsid) params.permsid = req.query.permsid;
|
||||
res.json(await getClient(req).execute(getSid(req), 'permfind', params));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
permissionRoutes.get('/overview/:cldbid', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'permoverview', {
|
||||
cldbid: String(req.params.cldbid),
|
||||
cid: req.query.cid || 0,
|
||||
permid: req.query.permid || 0,
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
|
||||
export const playlistRoutes: Router = Router();
|
||||
|
||||
playlistRoutes.use(requireRole('admin'));
|
||||
|
||||
// GET / — List playlists
|
||||
playlistRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const musicBotId = req.query.musicBotId ? parseInt(String(req.query.musicBotId)) : undefined;
|
||||
const playlists = await prisma.playlist.findMany({
|
||||
where: musicBotId ? { musicBotId } : undefined,
|
||||
include: { _count: { select: { songs: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(playlists.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
musicBotId: p.musicBotId,
|
||||
songCount: p._count.songs,
|
||||
createdAt: p.createdAt,
|
||||
})));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id — Get playlist with songs
|
||||
playlistRoutes.get('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const playlist = await prisma.playlist.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
songs: {
|
||||
include: { song: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!playlist) throw new AppError(404, 'Playlist not found');
|
||||
|
||||
res.json({
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
musicBotId: playlist.musicBotId,
|
||||
songCount: playlist.songs.length,
|
||||
createdAt: playlist.createdAt,
|
||||
songs: playlist.songs.map((ps: any) => ({
|
||||
id: ps.song.id,
|
||||
title: ps.song.title,
|
||||
artist: ps.song.artist,
|
||||
duration: ps.song.duration,
|
||||
source: ps.song.source,
|
||||
sourceUrl: ps.song.sourceUrl,
|
||||
fileSize: ps.song.fileSize,
|
||||
createdAt: ps.song.createdAt,
|
||||
position: ps.position,
|
||||
})),
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST / — Create playlist
|
||||
playlistRoutes.post('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const { name, musicBotId } = req.body;
|
||||
if (!name) throw new AppError(400, 'name is required');
|
||||
|
||||
const playlist = await prisma.playlist.create({
|
||||
data: {
|
||||
name,
|
||||
musicBotId: musicBotId ? parseInt(musicBotId) : null,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ id: playlist.id, name: playlist.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /:id — Update playlist
|
||||
playlistRoutes.put('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { name, musicBotId } = req.body;
|
||||
|
||||
await prisma.playlist.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name != null && { name }),
|
||||
...(musicBotId !== undefined && { musicBotId: musicBotId ? parseInt(musicBotId) : null }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id — Delete playlist
|
||||
playlistRoutes.delete('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
await prisma.playlist.delete({ where: { id: parseInt(req.params.id as string) } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/songs — Add song to playlist
|
||||
playlistRoutes.post('/:id/songs', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const playlistId = parseInt(req.params.id as string);
|
||||
const { songId } = req.body;
|
||||
if (!songId) throw new AppError(400, 'songId is required');
|
||||
|
||||
// Get next position
|
||||
const maxPos = await prisma.playlistSong.aggregate({
|
||||
where: { playlistId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const nextPosition = (maxPos._max.position ?? -1) + 1;
|
||||
|
||||
await prisma.playlistSong.create({
|
||||
data: {
|
||||
playlistId,
|
||||
songId: parseInt(songId),
|
||||
position: nextPosition,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id/songs/:songId — Remove song from playlist
|
||||
playlistRoutes.delete('/:id/songs/:songId', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const playlistId = parseInt(req.params.id as string);
|
||||
const songId = parseInt(req.params.songId as string);
|
||||
|
||||
await prisma.playlistSong.deleteMany({
|
||||
where: { playlistId, songId },
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /:id/songs/reorder — Reorder songs
|
||||
playlistRoutes.put('/:id/songs/reorder', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const playlistId = parseInt(req.params.id as string);
|
||||
const { songIds } = req.body;
|
||||
if (!Array.isArray(songIds)) throw new AppError(400, 'songIds array is required');
|
||||
|
||||
// Update positions in a transaction
|
||||
await prisma.$transaction(
|
||||
songIds.map((songId: number, index: number) =>
|
||||
prisma.playlistSong.updateMany({
|
||||
where: { playlistId, songId },
|
||||
data: { position: index },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { RadioPreset } from '@ts6/common';
|
||||
|
||||
export const radioStationRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
radioStationRoutes.use(requireRole('admin'));
|
||||
|
||||
// Built-in radio station presets
|
||||
const RADIO_PRESETS: RadioPreset[] = [
|
||||
{ name: '1LIVE', url: 'https://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3', genre: 'Pop/Rock' },
|
||||
{ name: 'WDR 2', url: 'https://wdr-wdr2-rheinland.icecast.wdr.de/wdr/wdr2/rheinland/mp3/128/stream.mp3', genre: 'Pop' },
|
||||
{ name: 'SWR3', url: 'https://liveradio.swr.de/sw282p3/swr3/play.mp3', genre: 'Pop' },
|
||||
{ name: 'BigFM', url: 'https://streams.bigfm.de/bigfm-deutschland-128-mp3', genre: 'Pop/Dance' },
|
||||
{ name: 'Technobase.FM', url: 'https://listen.technobase.fm/tunein-mp3-pls', genre: 'Techno/Dance' },
|
||||
{ name: 'HardBase.FM', url: 'https://listen.hardbase.fm/tunein-mp3-pls', genre: 'Hardstyle' },
|
||||
{ name: 'HouseTime.FM', url: 'https://listen.housetime.fm/tunein-mp3-pls', genre: 'House' },
|
||||
{ name: 'TranceBase.FM', url: 'https://listen.trancebase.fm/tunein-mp3-pls', genre: 'Trance' },
|
||||
{ name: 'Lofi Hip Hop', url: 'https://play.streamafrica.net/lofiradio', genre: 'Lofi/Chill' },
|
||||
{ name: 'BBC Radio 1', url: 'http://stream.live.vc.bbcmedia.co.uk/bbc_radio_one', genre: 'Pop/Chart' },
|
||||
{ name: 'Classic FM', url: 'https://media-ice.musicradio.com/ClassicFMMP3', genre: 'Classical' },
|
||||
{ name: 'Absolute Radio', url: 'https://ais-sa5.cdnstream1.com/b75154_128mp3', genre: 'Rock' },
|
||||
{ name: 'Jazz Radio', url: 'http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3', genre: 'Jazz' },
|
||||
{ name: 'Sunshine Live', url: 'https://stream.sunshine-live.de/live/mp3-192/stream.sunshine-live.de/', genre: 'Electronic' },
|
||||
{ name: 'Radio BOB!', url: 'https://streams.radiobob.de/bob-live/mp3-192/mediaplayer', genre: 'Rock' },
|
||||
{ name: 'Deutschlandfunk', url: 'https://st01.dlf.de/dlf/01/128/mp3/stream.mp3', genre: 'News/Culture' },
|
||||
];
|
||||
|
||||
// GET /presets — Built-in radio station presets
|
||||
radioStationRoutes.get('/presets', (_req: Request, res: Response) => {
|
||||
res.json(RADIO_PRESETS);
|
||||
});
|
||||
|
||||
// GET / — List radio stations for this server
|
||||
radioStationRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const stations = await prisma.radioStation.findMany({
|
||||
where: { serverConfigId: configId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json(stations);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST / — Add radio station
|
||||
radioStationRoutes.post('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const configId = parseInt(req.params.configId as string);
|
||||
const { name, url, genre, imageUrl } = req.body;
|
||||
if (!name || !url) throw new AppError(400, 'name and url are required');
|
||||
|
||||
const station = await prisma.radioStation.create({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
genre: genre || null,
|
||||
imageUrl: imageUrl || null,
|
||||
serverConfigId: configId,
|
||||
},
|
||||
});
|
||||
res.status(201).json(station);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id — Remove radio station
|
||||
radioStationRoutes.delete('/:id', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(req.params.id as string);
|
||||
await prisma.radioStation.delete({ where: { id } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const serverGroupRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
serverGroupRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'servergrouplist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.status(201).json(await getClient(req).execute(getSid(req), 'servergroupadd', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.put('/:sgid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergrouprename', { sgid: String(req.params.sgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.delete('/:sgid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupdel', { sgid: String(req.params.sgid), force: 1 }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.post('/:sgid/copy', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupcopy', { ssgid: String(req.params.sgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.get('/:sgid/members', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupclientlist', { sgid: String(req.params.sgid), '-names': '' }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.post('/:sgid/members', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupaddclient', { sgid: String(req.params.sgid), cldbid: req.body.cldbid }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.delete('/:sgid/members/:cldbid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupdelclient', { sgid: String(req.params.sgid), cldbid: String(req.params.cldbid) }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.get('/:sgid/permissions', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergrouppermlist', { sgid: String(req.params.sgid), '-permsid': '' }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.put('/:sgid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupaddperm', { sgid: String(req.params.sgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
serverGroupRoutes.delete('/:sgid/permissions', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json(await getClient(req).execute(getSid(req), 'servergroupdelperm', { sgid: String(req.params.sgid), ...req.body }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { WebQueryClient } from '../ts-client/webquery-client.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const serverRoutes: Router = Router();
|
||||
|
||||
// List all configured TS server connections
|
||||
serverRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const servers = await prisma.tsServerConfig.findMany({
|
||||
select: {
|
||||
id: true, name: true, host: true, webqueryPort: true,
|
||||
useHttps: true, sshPort: true, enabled: true,
|
||||
createdAt: true, sshUsername: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
res.json(servers.map((s: any) => ({
|
||||
...s,
|
||||
hasSshCredentials: !!s.sshUsername,
|
||||
sshUsername: undefined,
|
||||
})));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Add new TS server connection
|
||||
serverRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { name, host, webqueryPort, apiKey, useHttps, sshPort, sshUsername, sshPassword } = req.body;
|
||||
if (!name || !host || !apiKey) throw new AppError(400, 'Name, host, and API key are required');
|
||||
|
||||
const prisma = req.app.locals.prisma;
|
||||
const server = await prisma.tsServerConfig.create({
|
||||
data: {
|
||||
name,
|
||||
host,
|
||||
webqueryPort: webqueryPort || 10080,
|
||||
apiKey,
|
||||
useHttps: useHttps || false,
|
||||
sshPort: sshPort || 10022,
|
||||
sshUsername: sshUsername || null,
|
||||
sshPassword: sshPassword || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Add to connection pool
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
pool.addClient(server.id, server.host, server.webqueryPort, server.apiKey, server.useHttps);
|
||||
|
||||
res.status(201).json({ id: server.id, name: server.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get server connection details
|
||||
serverRoutes.get('/:configId', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const server = await prisma.tsServerConfig.findUnique({
|
||||
where: { id: parseInt(String(req.params.configId)) },
|
||||
});
|
||||
if (!server) throw new AppError(404, 'Server config not found');
|
||||
|
||||
res.json({
|
||||
id: server.id, name: server.name, host: server.host,
|
||||
webqueryPort: server.webqueryPort, useHttps: server.useHttps,
|
||||
sshPort: server.sshPort, hasSshCredentials: !!server.sshUsername,
|
||||
enabled: server.enabled, createdAt: server.createdAt,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update server connection
|
||||
serverRoutes.put('/:configId', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(String(req.params.configId));
|
||||
const data: any = {};
|
||||
|
||||
const fields = ['name', 'host', 'webqueryPort', 'apiKey', 'useHttps', 'sshPort', 'sshUsername', 'sshPassword', 'enabled'];
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
// Don't overwrite API key or SSH password with empty strings
|
||||
if ((field === 'apiKey' || field === 'sshPassword') && req.body[field] === '') continue;
|
||||
data[field] = req.body[field];
|
||||
}
|
||||
}
|
||||
|
||||
const server = await prisma.tsServerConfig.update({ where: { id }, data });
|
||||
|
||||
// Refresh connection pool
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
await pool.refreshClient(id);
|
||||
|
||||
res.json({ id: server.id, name: server.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete server connection
|
||||
serverRoutes.delete('/:configId', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(String(req.params.configId));
|
||||
await prisma.tsServerConfig.delete({ where: { id } });
|
||||
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
pool.removeClient(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Test connection
|
||||
serverRoutes.post('/:configId/test', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const server = await prisma.tsServerConfig.findUnique({
|
||||
where: { id: parseInt(String(req.params.configId)) },
|
||||
});
|
||||
if (!server) throw new AppError(404, 'Server config not found');
|
||||
|
||||
const client = new WebQueryClient(server.host, server.webqueryPort, server.apiKey, server.useHttps);
|
||||
const ok = await client.testConnection();
|
||||
client.destroy(); // Close the temporary TCP connection immediately
|
||||
|
||||
res.json({ success: ok });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const tokenRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
const getSid = (req: Request) => parseInt(String(req.params.sid));
|
||||
|
||||
tokenRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'privilegekeylist')); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
tokenRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.status(201).json(await getClient(req).execute(getSid(req), 'privilegekeyadd', req.body)); } catch (err) { next(err); }
|
||||
});
|
||||
|
||||
tokenRoutes.delete('/:token', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try { res.json(await getClient(req).execute(getSid(req), 'privilegekeydelete', { token: String(req.params.token) })); } catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
|
||||
export const userRoutes: Router = Router();
|
||||
|
||||
userRoutes.use(requireRole('admin'));
|
||||
|
||||
userRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, username: true, displayName: true, role: true, enabled: true, createdAt: true, lastLoginAt: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
res.json(users);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
userRoutes.post('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { username, password, displayName, role } = req.body;
|
||||
if (!username || !password || !displayName) throw new AppError(400, 'Username, password, and display name required');
|
||||
|
||||
const prisma = req.app.locals.prisma;
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { username, passwordHash, displayName, role: role || 'viewer' },
|
||||
});
|
||||
|
||||
res.status(201).json({ id: user.id, username: user.username });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
userRoutes.put('/:userId', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(String(req.params.userId));
|
||||
const data: any = {};
|
||||
|
||||
if (req.body.displayName !== undefined) data.displayName = req.body.displayName;
|
||||
if (req.body.role !== undefined) data.role = req.body.role;
|
||||
if (req.body.enabled !== undefined) data.enabled = req.body.enabled;
|
||||
if (req.body.password) data.passwordHash = await bcrypt.hash(req.body.password, 12);
|
||||
|
||||
await prisma.user.update({ where: { id }, data });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
userRoutes.delete('/:userId', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const prisma = req.app.locals.prisma;
|
||||
const id = parseInt(String(req.params.userId));
|
||||
if (id === req.user!.id) throw new AppError(400, 'Cannot delete your own account');
|
||||
await prisma.user.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import type { ConnectionPool } from '../ts-client/connection-pool.js';
|
||||
|
||||
export const virtualServerRoutes: Router = Router({ mergeParams: true });
|
||||
|
||||
const getClient = (req: Request) => {
|
||||
const pool: ConnectionPool = req.app.locals.connectionPool;
|
||||
return pool.getClient(parseInt(String(req.params.configId)));
|
||||
};
|
||||
|
||||
virtualServerRoutes.get('/', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(0, 'serverlist');
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.get('/:sid/info', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(sid, 'serverinfo');
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.put('/:sid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(sid, 'serveredit', req.body);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const result = await getClient(req).execute(0, 'servercreate', req.body);
|
||||
res.status(201).json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.post('/:sid/start', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(0, 'serverstart', { sid });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.post('/:sid/stop', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(0, 'serverstop', { sid });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.delete('/:sid', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(0, 'serverdelete', { sid });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.post('/:sid/snapshot', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(sid, 'serversnapshotcreate');
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.post('/:sid/snapshot/deploy', requireRole('admin'), async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).executePost(sid, 'serversnapshotdeploy', req.body);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
virtualServerRoutes.get('/:sid/connection-info', async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
const sid = parseInt(String(req.params.sid));
|
||||
const result = await getClient(req).execute(sid, 'serverrequestconnectioninfo');
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import { WebQueryClient } from './webquery-client.js';
|
||||
|
||||
export class ConnectionPool {
|
||||
private clients: Map<number, WebQueryClient> = new Map();
|
||||
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const servers = await this.prisma.tsServerConfig.findMany({
|
||||
where: { enabled: true },
|
||||
});
|
||||
|
||||
for (const server of servers) {
|
||||
this.addClient(server.id, server.host, server.webqueryPort, server.apiKey, server.useHttps);
|
||||
}
|
||||
|
||||
console.log(`[ConnectionPool] Initialized ${this.clients.size} server connection(s)`);
|
||||
}
|
||||
|
||||
addClient(id: number, host: string, port: number, apiKey: string, useHttps: boolean): void {
|
||||
const client = new WebQueryClient(host, port, apiKey, useHttps);
|
||||
this.clients.set(id, client);
|
||||
}
|
||||
|
||||
removeClient(id: number): void {
|
||||
const client = this.clients.get(id);
|
||||
if (client) {
|
||||
client.destroy();
|
||||
this.clients.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
getClient(configId: number): WebQueryClient {
|
||||
const client = this.clients.get(configId);
|
||||
if (!client) {
|
||||
throw new Error(`No connection configured for server config ID ${configId}`);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
hasClient(configId: number): boolean {
|
||||
return this.clients.has(configId);
|
||||
}
|
||||
|
||||
async refreshClient(configId: number): Promise<void> {
|
||||
const server = await this.prisma.tsServerConfig.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
if (server && server.enabled) {
|
||||
this.addClient(server.id, server.host, server.webqueryPort, server.apiKey, server.useHttps);
|
||||
} else {
|
||||
this.removeClient(configId);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.destroy();
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import { TSApiError } from '../middleware/error-handler.js';
|
||||
|
||||
export class WebQueryClient {
|
||||
private http: AxiosInstance;
|
||||
private agent: http.Agent | https.Agent;
|
||||
|
||||
constructor(
|
||||
host: string,
|
||||
port: number,
|
||||
apiKey: string,
|
||||
useHttps: boolean = false,
|
||||
) {
|
||||
const protocol = useHttps ? 'https' : 'http';
|
||||
|
||||
// Use a single persistent TCP connection (keep-alive) to the TS WebQuery API.
|
||||
// Without this, each concurrent request opens a new TCP connection, and the
|
||||
// TS server registers each one as a separate "serveradmin" query client
|
||||
// (serveradmin, serveradmin1, serveradmin2, ...).
|
||||
this.agent = useHttps
|
||||
? new https.Agent({ keepAlive: true, maxSockets: 1 })
|
||||
: new http.Agent({ keepAlive: true, maxSockets: 1 });
|
||||
|
||||
this.http = axios.create({
|
||||
baseURL: `${protocol}://${host}:${port}`,
|
||||
headers: { 'x-api-key': apiKey },
|
||||
timeout: 15000,
|
||||
httpAgent: useHttps ? undefined : this.agent,
|
||||
httpsAgent: useHttps ? this.agent : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(sid: number, command: string, params?: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
// WebQuery URL pattern: /{sid}/{command}
|
||||
// For instance-level commands (sid=0): /{command}
|
||||
const path = sid > 0 ? `/${sid}/${command}` : `/${command}`;
|
||||
|
||||
const response = await this.http.get(path, {
|
||||
params: this.cleanParams(params),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.status && data.status.code !== 0) {
|
||||
throw new TSApiError(data.status.code, data.status.message);
|
||||
}
|
||||
|
||||
return data.body || data;
|
||||
} catch (error: any) {
|
||||
if (error instanceof TSApiError) throw error;
|
||||
if (error.response?.data?.status) {
|
||||
throw new TSApiError(
|
||||
error.response.data.status.code,
|
||||
error.response.data.status.message,
|
||||
);
|
||||
}
|
||||
throw new TSApiError(-1, error.message || 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
async executePost(sid: number, command: string, params?: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
const path = sid > 0 ? `/${sid}/${command}` : `/${command}`;
|
||||
const response = await this.http.post(path, null, {
|
||||
params: this.cleanParams(params),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.status && data.status.code !== 0) {
|
||||
throw new TSApiError(data.status.code, data.status.message);
|
||||
}
|
||||
|
||||
return data.body || data;
|
||||
} catch (error: any) {
|
||||
if (error instanceof TSApiError) throw error;
|
||||
if (error.response?.data?.status) {
|
||||
throw new TSApiError(
|
||||
error.response.data.status.code,
|
||||
error.response.data.status.message,
|
||||
);
|
||||
}
|
||||
throw new TSApiError(-1, error.message || 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove undefined/null values from params
|
||||
private cleanParams(params?: Record<string, any>): Record<string, any> | undefined {
|
||||
if (!params) return undefined;
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.execute(0, 'version');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy the HTTP agent, closing all keep-alive sockets.
|
||||
// Call this for temporary clients (e.g. test connection) to avoid lingering query logins.
|
||||
destroy(): void {
|
||||
this.agent.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import net from 'net';
|
||||
import tls from 'tls';
|
||||
|
||||
/**
|
||||
* Fetch the current StreamTitle from an ICY/Shoutcast/Icecast radio stream.
|
||||
*
|
||||
* Uses a raw TCP socket to handle both standard HTTP and the non-standard
|
||||
* ICY protocol (which responds with "ICY 200 OK" instead of "HTTP/1.x 200 OK").
|
||||
* Node.js http module cannot parse ICY responses.
|
||||
*
|
||||
* Follows HTTP redirects (301/302/303/307/308) up to 5 hops.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Send HTTP GET with `Icy-MetaData: 1` header
|
||||
* 2. Read `icy-metaint` from response headers
|
||||
* 3. Skip metaInt bytes of audio data
|
||||
* 4. Read 1 byte (metadata length = byte * 16)
|
||||
* 5. Read metadata string, parse StreamTitle
|
||||
* 6. Close connection
|
||||
*/
|
||||
export async function fetchIcyMetadata(streamUrl: string, timeoutMs = 10000): Promise<string | null> {
|
||||
let currentUrl = streamUrl;
|
||||
const maxRedirects = 5;
|
||||
|
||||
for (let i = 0; i <= maxRedirects; i++) {
|
||||
const result = await fetchIcySingle(currentUrl, timeoutMs);
|
||||
if (result.redirect) {
|
||||
currentUrl = result.redirect;
|
||||
continue;
|
||||
}
|
||||
return result.title;
|
||||
}
|
||||
return null; // Too many redirects
|
||||
}
|
||||
|
||||
interface IcyResult {
|
||||
title: string | null;
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
function fetchIcySingle(streamUrl: string, timeoutMs: number): Promise<IcyResult> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const url = new URL(streamUrl);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const host = url.hostname;
|
||||
const port = parseInt(url.port) || (isHttps ? 443 : 80);
|
||||
const path = url.pathname + url.search;
|
||||
|
||||
// Build raw HTTP request
|
||||
const request =
|
||||
`GET ${path || '/'} HTTP/1.0\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`User-Agent: TS6-MusicBot/1.0\r\n` +
|
||||
`Icy-MetaData: 1\r\n` +
|
||||
`Connection: close\r\n` +
|
||||
`\r\n`;
|
||||
|
||||
let socket: net.Socket;
|
||||
if (isHttps) {
|
||||
socket = tls.connect({ host, port, rejectUnauthorized: false }, () => {
|
||||
socket.write(request);
|
||||
});
|
||||
} else {
|
||||
socket = net.connect({ host, port }, () => {
|
||||
socket.write(request);
|
||||
});
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
let headersParsed = false;
|
||||
let metaInt = 0;
|
||||
let headersEndOffset = 0;
|
||||
|
||||
const timer = setTimeout(() => done({ title: null }), timeoutMs);
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
if (resolved) return;
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
// Phase 1: Parse response headers
|
||||
if (!headersParsed) {
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd < 0) return; // Need more data
|
||||
|
||||
const headersRaw = buffer.subarray(0, headerEnd).toString('latin1');
|
||||
const headersLower = headersRaw.toLowerCase();
|
||||
headersParsed = true;
|
||||
headersEndOffset = headerEnd + 4;
|
||||
|
||||
// Check for redirect (301, 302, 303, 307, 308)
|
||||
const statusLine = headersRaw.split('\r\n')[0];
|
||||
if (/\b30[12378]\b/.test(statusLine)) {
|
||||
const locMatch = headersRaw.match(/[Ll]ocation:\s*(.+)/);
|
||||
if (locMatch) {
|
||||
return done({ title: null, redirect: locMatch[1].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract icy-metaint
|
||||
const metaIntMatch = headersLower.match(/icy-metaint\s*:\s*(\d+)/);
|
||||
if (!metaIntMatch) {
|
||||
return done({ title: null }); // No ICY metadata support
|
||||
}
|
||||
metaInt = parseInt(metaIntMatch[1]);
|
||||
if (!metaInt || metaInt <= 0) {
|
||||
return done({ title: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Read audio data + metadata block
|
||||
const bodyStart = headersEndOffset;
|
||||
const bodyLen = buffer.length - bodyStart;
|
||||
|
||||
// Need at least metaInt bytes of audio + 1 byte metadata length
|
||||
if (bodyLen <= metaInt) return;
|
||||
|
||||
const metaLenByte = buffer[bodyStart + metaInt];
|
||||
const metaLen = metaLenByte * 16;
|
||||
|
||||
if (metaLen === 0) {
|
||||
// No metadata at this position — could mean empty title
|
||||
return done({ title: null });
|
||||
}
|
||||
|
||||
// Need the full metadata block
|
||||
if (bodyLen < metaInt + 1 + metaLen) return;
|
||||
|
||||
const metaStr = buffer.subarray(bodyStart + metaInt + 1, bodyStart + metaInt + 1 + metaLen).toString('utf-8');
|
||||
const match = metaStr.match(/StreamTitle='([^']*?)'/);
|
||||
done({ title: match ? match[1].trim() || null : null });
|
||||
});
|
||||
|
||||
socket.on('error', () => done({ title: null }));
|
||||
socket.on('end', () => done({ title: null }));
|
||||
|
||||
function done(result: IcyResult) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
try { socket.destroy(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import type { Readable } from "stream";
|
||||
import OpusScript from "opusscript";
|
||||
|
||||
export const SAMPLE_RATE = 48000;
|
||||
export const CHANNELS = 1;
|
||||
export const FRAME_SIZE = 960; // 20ms at 48kHz
|
||||
export const BYTES_PER_FRAME = FRAME_SIZE * CHANNELS * 2; // 16-bit = 2 bytes per sample
|
||||
export const FRAME_MS = 20;
|
||||
const BITRATE = 96000;
|
||||
|
||||
export class AudioPipeline {
|
||||
private encoder: OpusScript;
|
||||
|
||||
constructor() {
|
||||
this.encoder = new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO);
|
||||
this.encoder.setBitrate(BITRATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio file to raw PCM (48kHz, mono, s16le) at full volume.
|
||||
*/
|
||||
toPcm(filePath: string): Promise<Buffer> {
|
||||
return this.ffmpegToPcm(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split raw PCM buffer into 960-sample frames.
|
||||
*/
|
||||
splitFrames(pcmData: Buffer): Buffer[] {
|
||||
const frames: Buffer[] = [];
|
||||
for (let offset = 0; offset < pcmData.length; offset += BYTES_PER_FRAME) {
|
||||
let frame = pcmData.subarray(offset, offset + BYTES_PER_FRAME);
|
||||
if (frame.length < BYTES_PER_FRAME) {
|
||||
const padded = Buffer.alloc(BYTES_PER_FRAME, 0);
|
||||
frame.copy(padded);
|
||||
frame = padded;
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a single PCM frame to Opus, applying volume scaling in real-time.
|
||||
*/
|
||||
encodeFrame(pcmFrame: Buffer, volume: number): Buffer {
|
||||
let input = pcmFrame;
|
||||
if (volume !== 100) {
|
||||
const scaled = Buffer.alloc(pcmFrame.length);
|
||||
const factor = volume / 100;
|
||||
for (let i = 0; i < pcmFrame.length; i += 2) {
|
||||
const sample = pcmFrame.readInt16LE(i);
|
||||
const v = Math.round(sample * factor);
|
||||
scaled.writeInt16LE(Math.max(-32768, Math.min(32767, v)), i);
|
||||
}
|
||||
input = scaled;
|
||||
}
|
||||
return Buffer.from(this.encoder.encode(input, FRAME_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream audio from a URL to raw PCM (for live radio streams).
|
||||
* Returns a readable stdout stream + kill function. Does NOT buffer the entire stream.
|
||||
*/
|
||||
toPcmStream(url: string): { stdout: Readable; process: ChildProcess; kill: () => void } {
|
||||
const args = [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-i", url,
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-ar", String(SAMPLE_RATE),
|
||||
"-ac", String(CHANNELS),
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
];
|
||||
|
||||
const ffmpeg = spawn("ffmpeg", args, { shell: false });
|
||||
return {
|
||||
stdout: ffmpeg.stdout,
|
||||
process: ffmpeg,
|
||||
kill: () => {
|
||||
try { ffmpeg.kill("SIGKILL"); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private ffmpegToPcm(input: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"-i", input,
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-ar", String(SAMPLE_RATE),
|
||||
"-ac", String(CHANNELS),
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
];
|
||||
|
||||
const ffmpeg = spawn("ffmpeg", args, { shell: false });
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
ffmpeg.stdout.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
ffmpeg.stderr.on("data", () => {});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(chunks));
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export interface YouTubeInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number; // seconds
|
||||
thumbnail: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface YouTubeSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download audio from a YouTube URL using yt-dlp
|
||||
*/
|
||||
export function downloadYouTube(url: string, outputDir: string): Promise<{ filePath: string; info: YouTubeInfo }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputTemplate = path.join(outputDir, "%(id)s.%(ext)s");
|
||||
|
||||
// First get info
|
||||
const infoProc = spawn("yt-dlp", [
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
url,
|
||||
], { shell: false });
|
||||
|
||||
let infoJson = "";
|
||||
let infoErr = "";
|
||||
infoProc.stdout.on("data", (chunk: Buffer) => {
|
||||
infoJson += chunk.toString();
|
||||
});
|
||||
infoProc.stderr.on("data", (chunk: Buffer) => {
|
||||
infoErr += chunk.toString();
|
||||
});
|
||||
|
||||
infoProc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`yt-dlp info failed (code ${code}): ${infoErr.slice(0, 200)}`));
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(infoJson);
|
||||
} catch {
|
||||
return reject(new Error("Failed to parse yt-dlp output"));
|
||||
}
|
||||
|
||||
const info: YouTubeInfo = {
|
||||
id: parsed.id,
|
||||
title: parsed.title || "Unknown",
|
||||
artist: parsed.uploader || parsed.channel || "Unknown",
|
||||
duration: parsed.duration || 0,
|
||||
thumbnail: parsed.thumbnail || "",
|
||||
url,
|
||||
};
|
||||
|
||||
const expectedPath = path.join(outputDir, `${info.id}.opus`);
|
||||
|
||||
// Check if already downloaded
|
||||
if (fs.existsSync(expectedPath)) {
|
||||
return resolve({ filePath: expectedPath, info });
|
||||
}
|
||||
|
||||
// Download audio only
|
||||
const dlProc = spawn("yt-dlp", [
|
||||
"-x", // extract audio
|
||||
"--audio-format", "opus", // opus format (native for TS3)
|
||||
"--audio-quality", "0", // best quality
|
||||
"--no-playlist",
|
||||
"-o", outputTemplate,
|
||||
url,
|
||||
], { shell: false });
|
||||
|
||||
let dlErr = "";
|
||||
dlProc.stderr.on("data", (chunk: Buffer) => {
|
||||
dlErr += chunk.toString();
|
||||
});
|
||||
|
||||
dlProc.on("close", (dlCode) => {
|
||||
if (dlCode !== 0) {
|
||||
return reject(new Error(`yt-dlp download failed (code ${dlCode}): ${dlErr.slice(0, 200)}`));
|
||||
}
|
||||
|
||||
// yt-dlp may use different extensions, find the actual file
|
||||
const files = fs.readdirSync(outputDir).filter((f) => f.startsWith(info.id));
|
||||
if (files.length === 0) {
|
||||
return reject(new Error("Downloaded file not found"));
|
||||
}
|
||||
|
||||
const filePath = path.join(outputDir, files[files.length - 1]);
|
||||
resolve({ filePath, info });
|
||||
});
|
||||
|
||||
dlProc.on("error", (err) => {
|
||||
reject(new Error(`yt-dlp not found: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
infoProc.on("error", (err) => {
|
||||
reject(new Error(`yt-dlp not found: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get info about a YouTube URL (single video or playlist).
|
||||
* Returns type ('video' or 'playlist') and array of items.
|
||||
*/
|
||||
export function getYouTubeUrlInfo(url: string): Promise<{ type: 'video' | 'playlist'; items: YouTubeSearchResult[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("yt-dlp", [
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-download",
|
||||
url,
|
||||
], { shell: false });
|
||||
|
||||
let output = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`yt-dlp info failed (code ${code}): ${stderr.slice(0, 200)}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
const items: YouTubeSearchResult[] = lines.map((line) => {
|
||||
const parsed = JSON.parse(line);
|
||||
return {
|
||||
id: parsed.id,
|
||||
title: parsed.title || "Unknown",
|
||||
artist: parsed.uploader || parsed.channel || "Unknown",
|
||||
duration: parsed.duration || 0,
|
||||
thumbnail: parsed.thumbnails?.[0]?.url || parsed.thumbnail || "",
|
||||
};
|
||||
});
|
||||
|
||||
const type = items.length > 1 ? 'playlist' : 'video';
|
||||
resolve({ type, items });
|
||||
} catch {
|
||||
reject(new Error("Failed to parse yt-dlp output"));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(new Error(`yt-dlp not found: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search YouTube using yt-dlp
|
||||
*/
|
||||
export function searchYouTube(query: string, maxResults: number = 10): Promise<YouTubeSearchResult[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("yt-dlp", [
|
||||
`ytsearch${maxResults}:${query}`,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-download",
|
||||
], { shell: false });
|
||||
|
||||
let output = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`yt-dlp search failed (code ${code}): ${stderr.slice(0, 200)}`));
|
||||
}
|
||||
|
||||
try {
|
||||
// yt-dlp outputs one JSON object per line
|
||||
const results = output
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parsed = JSON.parse(line);
|
||||
return {
|
||||
id: parsed.id,
|
||||
title: parsed.title || "Unknown",
|
||||
artist: parsed.uploader || parsed.channel || "Unknown",
|
||||
duration: parsed.duration || 0,
|
||||
thumbnail: parsed.thumbnails?.[0]?.url || "",
|
||||
};
|
||||
});
|
||||
|
||||
resolve(results);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(new Error(`yt-dlp not found: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { VoiceBot } from './voice-bot.js';
|
||||
export type { VoiceBotConfig, VoiceBotStatus, PlaybackProgress } from './voice-bot.js';
|
||||
export { VoiceBotManager } from './voice-bot-manager.js';
|
||||
export { AudioPipeline, SAMPLE_RATE, CHANNELS, FRAME_SIZE, FRAME_MS } from './audio/pipeline.js';
|
||||
export { downloadYouTube, searchYouTube } from './audio/youtube.js';
|
||||
export type { YouTubeInfo, YouTubeSearchResult } from './audio/youtube.js';
|
||||
export { PlayQueue } from './playlist/queue.js';
|
||||
export type { QueueItem, RepeatMode } from './playlist/queue.js';
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import { VoiceBotManager } from './voice-bot-manager.js';
|
||||
import type { VoiceBot } from './voice-bot.js';
|
||||
import type { QueueItem } from './playlist/queue.js';
|
||||
import { downloadYouTube } from './audio/youtube.js';
|
||||
|
||||
const MUSIC_DIR = process.env.MUSIC_DIR || '/data/music';
|
||||
const CMD_PREFIX = '!';
|
||||
|
||||
const MUSIC_COMMANDS = new Set([
|
||||
'radio', 'play', 'stop', 'pause', 'skip', 'next', 'prev',
|
||||
'vol', 'volume', 'np', 'nowplaying',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Handles text-based music commands (!radio, !play, !stop, etc.)
|
||||
* by listening directly on each VoiceBot's TS3 connection.
|
||||
*
|
||||
* The bot receives `notifytextmessage` in its own channel —
|
||||
* no SSH EventBridge needed.
|
||||
*/
|
||||
export class MusicCommandHandler {
|
||||
private registeredBots = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private voiceBotManager: VoiceBotManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register text message listener on a VoiceBot instance.
|
||||
* Called by VoiceBotManager whenever a bot is created/started.
|
||||
*/
|
||||
registerBot(botId: number, bot: VoiceBot): void {
|
||||
if (this.registeredBots.has(botId)) return;
|
||||
this.registeredBots.add(botId);
|
||||
|
||||
bot.on('textMessage', (data: Record<string, string>) => {
|
||||
this.onTextMessage(botId, bot, data).catch(err => {
|
||||
console.error(`[MusicCmd] Error processing text message on bot ${botId}: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[MusicCmd] Registered text command listener on bot ${botId}`);
|
||||
}
|
||||
|
||||
unregisterBot(botId: number): void {
|
||||
this.registeredBots.delete(botId);
|
||||
}
|
||||
|
||||
private async onTextMessage(botId: number, bot: VoiceBot, data: Record<string, string>): Promise<void> {
|
||||
const msg = (data.msg || '').trim();
|
||||
if (!msg.startsWith(CMD_PREFIX)) return;
|
||||
|
||||
const parts = msg.substring(CMD_PREFIX.length).split(/\s+/);
|
||||
const command = parts[0].toLowerCase();
|
||||
if (!MUSIC_COMMANDS.has(command)) return;
|
||||
|
||||
const args = parts.slice(1).join(' ').trim();
|
||||
const userClid = parseInt(data.invokerid || '0');
|
||||
if (!userClid) return;
|
||||
|
||||
// Ignore messages from ourselves (the bot)
|
||||
if (userClid === bot.ts3ClientId) return;
|
||||
|
||||
console.log(`[MusicCmd] Bot ${botId}: !${command} ${args} (from clid=${userClid})`);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'radio':
|
||||
await this.handleRadio(botId, bot, userClid, args);
|
||||
break;
|
||||
case 'play':
|
||||
await this.handlePlay(bot, userClid, args);
|
||||
break;
|
||||
case 'stop':
|
||||
this.handleStop(bot, userClid);
|
||||
break;
|
||||
case 'pause':
|
||||
this.handlePause(bot, userClid);
|
||||
break;
|
||||
case 'skip':
|
||||
case 'next':
|
||||
await this.handleSkip(bot, userClid);
|
||||
break;
|
||||
case 'prev':
|
||||
await this.handlePrev(bot, userClid);
|
||||
break;
|
||||
case 'vol':
|
||||
case 'volume':
|
||||
this.handleVolume(bot, userClid, args);
|
||||
break;
|
||||
case 'np':
|
||||
case 'nowplaying':
|
||||
this.handleNowPlaying(bot, userClid);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[MusicCmd] Error handling !${command}: ${err.message}`);
|
||||
this.reply(bot, userClid, `Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private reply(bot: VoiceBot, targetClid: number, msg: string): void {
|
||||
try {
|
||||
bot.sendTextMessage(targetClid, msg);
|
||||
} catch (err: any) {
|
||||
console.error(`[MusicCmd] Failed to send reply: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Command Handlers ───────────────────────────────────────
|
||||
|
||||
private async handleRadio(botId: number, bot: VoiceBot, userClid: number, args: string): Promise<void> {
|
||||
// Get serverConfigId for this bot from DB
|
||||
const dbBot = await this.prisma.musicBot.findUnique({ where: { id: botId }, select: { serverConfigId: true } });
|
||||
if (!dbBot) {
|
||||
this.reply(bot, userClid, 'Bot config not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const stations = await this.prisma.radioStation.findMany({
|
||||
where: { serverConfigId: dbBot.serverConfigId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
if (stations.length === 0) {
|
||||
this.reply(bot, userClid, 'No radio stations configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
// No argument — list stations
|
||||
if (!args) {
|
||||
const lines = stations.map((s: any) => `[${s.id}] ${s.name}${s.genre ? ` (${s.genre})` : ''}`);
|
||||
this.reply(bot, userClid, 'Radio Stations:\n' + lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Argument — play station by ID
|
||||
const stationId = parseInt(args);
|
||||
if (isNaN(stationId)) {
|
||||
this.reply(bot, userClid, 'Usage: !radio <id> — Use !radio to list stations.');
|
||||
return;
|
||||
}
|
||||
|
||||
const station = stations.find((s: any) => s.id === stationId);
|
||||
if (!station) {
|
||||
this.reply(bot, userClid, `Station #${stationId} not found. Use !radio to list stations.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem: QueueItem = {
|
||||
id: `radio_${station.id}`,
|
||||
title: station.name,
|
||||
artist: station.genre ?? 'Radio',
|
||||
filePath: '',
|
||||
source: 'radio',
|
||||
streamUrl: station.url,
|
||||
};
|
||||
|
||||
await bot.playStream(queueItem);
|
||||
this.reply(bot, userClid, `Now playing: ${station.name}`);
|
||||
}
|
||||
|
||||
private async handlePlay(bot: VoiceBot, userClid: number, args: string): Promise<void> {
|
||||
if (!args) {
|
||||
if (bot.status === 'paused') {
|
||||
bot.resume();
|
||||
this.reply(bot, userClid, 'Resumed.');
|
||||
return;
|
||||
}
|
||||
this.reply(bot, userClid, 'Usage: !play <youtube-url>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.startsWith('http://') && !args.startsWith('https://')) {
|
||||
this.reply(bot, userClid, 'Please provide a valid URL. Usage: !play <url>');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reply(bot, userClid, 'Loading...');
|
||||
|
||||
try {
|
||||
const { filePath, info } = await downloadYouTube(args, MUSIC_DIR);
|
||||
|
||||
const queueItem: QueueItem = {
|
||||
id: `yt_${info.id}`,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
duration: info.duration,
|
||||
filePath,
|
||||
source: 'youtube',
|
||||
sourceUrl: args,
|
||||
};
|
||||
|
||||
bot.queue.add(queueItem);
|
||||
bot.queue.playAt(bot.queue.length - 1);
|
||||
await bot.play(queueItem);
|
||||
|
||||
this.reply(bot, userClid, `Now playing: ${info.artist} - ${info.title}`);
|
||||
} catch (err: any) {
|
||||
this.reply(bot, userClid, `Failed to play: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStop(bot: VoiceBot, userClid: number): void {
|
||||
bot.stopAudio();
|
||||
this.reply(bot, userClid, 'Playback stopped.');
|
||||
}
|
||||
|
||||
private handlePause(bot: VoiceBot, userClid: number): void {
|
||||
if (bot.status === 'paused') {
|
||||
bot.resume();
|
||||
this.reply(bot, userClid, 'Resumed.');
|
||||
} else if (bot.status === 'playing') {
|
||||
bot.pause();
|
||||
this.reply(bot, userClid, 'Paused.');
|
||||
} else {
|
||||
this.reply(bot, userClid, 'Nothing is playing.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSkip(bot: VoiceBot, userClid: number): Promise<void> {
|
||||
const next = bot.queue.next();
|
||||
if (next) {
|
||||
if (next.streamUrl) {
|
||||
await bot.playStream(next);
|
||||
} else {
|
||||
await bot.play(next);
|
||||
}
|
||||
this.reply(bot, userClid, `Skipped to: ${next.title}`);
|
||||
} else {
|
||||
bot.stopAudio();
|
||||
this.reply(bot, userClid, 'Queue empty — playback stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePrev(bot: VoiceBot, userClid: number): Promise<void> {
|
||||
const prev = bot.queue.previous();
|
||||
if (prev) {
|
||||
if (prev.streamUrl) {
|
||||
await bot.playStream(prev);
|
||||
} else {
|
||||
await bot.play(prev);
|
||||
}
|
||||
this.reply(bot, userClid, `Previous: ${prev.title}`);
|
||||
} else {
|
||||
this.reply(bot, userClid, 'No previous track.');
|
||||
}
|
||||
}
|
||||
|
||||
private handleVolume(bot: VoiceBot, userClid: number, args: string): void {
|
||||
if (!args) {
|
||||
const vol = bot.currentConfig.volume;
|
||||
this.reply(bot, userClid, `Volume: ${vol}%`);
|
||||
return;
|
||||
}
|
||||
|
||||
const vol = parseInt(args);
|
||||
if (isNaN(vol) || vol < 0 || vol > 100) {
|
||||
this.reply(bot, userClid, 'Usage: !vol <0-100>');
|
||||
return;
|
||||
}
|
||||
|
||||
bot.setVolume(vol);
|
||||
this.reply(bot, userClid, `Volume set to ${vol}%.`);
|
||||
}
|
||||
|
||||
private handleNowPlaying(bot: VoiceBot, userClid: number): void {
|
||||
const np = bot.nowPlaying;
|
||||
if (!np) {
|
||||
this.reply(bot, userClid, 'Nothing is playing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artist = np.artist ? `${np.artist} - ` : '';
|
||||
this.reply(bot, userClid, `Now playing: ${artist}${np.title}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type RepeatMode = "off" | "track" | "queue";
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration?: number; // seconds
|
||||
filePath: string;
|
||||
source: "local" | "youtube" | "url" | "radio";
|
||||
sourceUrl?: string;
|
||||
streamUrl?: string; // If set, play as live stream (radio) instead of file
|
||||
}
|
||||
|
||||
export class PlayQueue {
|
||||
private items: QueueItem[] = [];
|
||||
private currentIndex = -1;
|
||||
private _shuffle = false;
|
||||
private _repeat: RepeatMode = "off";
|
||||
private shuffleOrder: number[] = [];
|
||||
|
||||
get current(): QueueItem | null {
|
||||
if (this.currentIndex < 0 || this.currentIndex >= this.items.length) {
|
||||
return null;
|
||||
}
|
||||
const idx = this._shuffle ? this.shuffleOrder[this.currentIndex] : this.currentIndex;
|
||||
return this.items[idx] ?? null;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
get repeat(): RepeatMode {
|
||||
return this._repeat;
|
||||
}
|
||||
|
||||
get shuffle(): boolean {
|
||||
return this._shuffle;
|
||||
}
|
||||
|
||||
getAll(): QueueItem[] {
|
||||
if (this._shuffle) {
|
||||
return this.shuffleOrder.map((i) => this.items[i]);
|
||||
}
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
add(item: QueueItem): void {
|
||||
this.items.push(item);
|
||||
if (this._shuffle) {
|
||||
// Insert new item at random position in shuffle order
|
||||
const pos = Math.floor(Math.random() * (this.shuffleOrder.length + 1));
|
||||
this.shuffleOrder.splice(pos, 0, this.items.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
addMany(items: QueueItem[]): void {
|
||||
for (const item of items) {
|
||||
this.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const idx = this.items.findIndex((item) => item.id === id);
|
||||
if (idx < 0) return false;
|
||||
|
||||
this.items.splice(idx, 1);
|
||||
|
||||
// Update shuffle order
|
||||
if (this._shuffle) {
|
||||
this.shuffleOrder = this.shuffleOrder
|
||||
.filter((i) => i !== idx)
|
||||
.map((i) => (i > idx ? i - 1 : i));
|
||||
}
|
||||
|
||||
// Adjust current index
|
||||
if (idx < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (idx === this.currentIndex) {
|
||||
this.currentIndex = Math.min(this.currentIndex, this.items.length - 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
this.currentIndex = -1;
|
||||
this.shuffleOrder = [];
|
||||
}
|
||||
|
||||
next(): QueueItem | null {
|
||||
if (this.items.length === 0) return null;
|
||||
|
||||
// Track repeat is handled by VoiceBot directly — here we just advance
|
||||
this.currentIndex++;
|
||||
|
||||
if (this.currentIndex >= this.items.length) {
|
||||
if (this._repeat === "queue") {
|
||||
this.currentIndex = 0;
|
||||
if (this._shuffle) {
|
||||
this.regenerateShuffleOrder();
|
||||
}
|
||||
} else {
|
||||
this.currentIndex = -1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
playAt(index: number): QueueItem | null {
|
||||
if (index < 0 || index >= this.items.length) return null;
|
||||
this.currentIndex = index;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
previous(): QueueItem | null {
|
||||
if (this.items.length === 0) return null;
|
||||
|
||||
this.currentIndex--;
|
||||
if (this.currentIndex < 0) {
|
||||
if (this._repeat === "queue") {
|
||||
this.currentIndex = this.items.length - 1;
|
||||
} else {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
setRepeat(mode: RepeatMode): void {
|
||||
this._repeat = mode;
|
||||
}
|
||||
|
||||
setShuffle(enabled: boolean): void {
|
||||
this._shuffle = enabled;
|
||||
if (enabled) {
|
||||
this.regenerateShuffleOrder();
|
||||
}
|
||||
}
|
||||
|
||||
private regenerateShuffleOrder(): void {
|
||||
this.shuffleOrder = Array.from({ length: this.items.length }, (_, i) => i);
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = this.shuffleOrder.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.shuffleOrder[i], this.shuffleOrder[j]] = [this.shuffleOrder[j], this.shuffleOrder[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
import * as dgram from "dgram";
|
||||
import * as crypto from "crypto";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
eaxEncrypt,
|
||||
eaxDecrypt,
|
||||
sha1,
|
||||
sha512,
|
||||
xorInto,
|
||||
deriveKeyNonce,
|
||||
ecdsaSign,
|
||||
MAC_LEN,
|
||||
INIT_MAC,
|
||||
DUMMY_KEY,
|
||||
DUMMY_NONCE,
|
||||
INIT_VERSION,
|
||||
hashPassword,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
IdentityData,
|
||||
exportPublicKeyString,
|
||||
getSharedSecret,
|
||||
} from "./identity.js";
|
||||
import {
|
||||
parseLicense,
|
||||
deriveLicenseKey,
|
||||
getSharedSecret2,
|
||||
generateTemporaryKey,
|
||||
} from "./license.js";
|
||||
import { buildCommand, parseCommand, type ParsedCommand } from "./commands.js";
|
||||
import { qlzDecompress } from "./quicklz.js";
|
||||
|
||||
// Packet types
|
||||
export const enum PacketType {
|
||||
Voice = 0,
|
||||
VoiceWhisper = 1,
|
||||
Command = 2,
|
||||
CommandLow = 3,
|
||||
Ping = 4,
|
||||
Pong = 5,
|
||||
Ack = 6,
|
||||
AckLow = 7,
|
||||
Init1 = 8,
|
||||
}
|
||||
|
||||
// Packet flags
|
||||
const FLAG_FRAGMENTED = 0x10;
|
||||
const FLAG_NEWPROTOCOL = 0x20;
|
||||
const FLAG_COMPRESSED = 0x40;
|
||||
const FLAG_UNENCRYPTED = 0x80;
|
||||
|
||||
// Header sizes (excluding MAC)
|
||||
const C2S_HEADER_LEN = 5; // PId(2) + CId(2) + PT(1)
|
||||
const S2C_HEADER_LEN = 3; // PId(2) + PT(1)
|
||||
const MAX_PACKET_SIZE = 500;
|
||||
const MAX_OUT_CONTENT = MAX_PACKET_SIZE - MAC_LEN - C2S_HEADER_LEN;
|
||||
|
||||
// Version sign - TS3AudioBot default (Linux 3.?.? far-future build)
|
||||
const VERSION_SIGN = {
|
||||
platform: "Linux",
|
||||
version: "3.?.? [Build: 5680278000]",
|
||||
sign: "Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ==",
|
||||
};
|
||||
|
||||
interface ResendPacket {
|
||||
raw: Buffer;
|
||||
packetId: number;
|
||||
firstSend: number;
|
||||
lastSend: number;
|
||||
}
|
||||
|
||||
export interface Ts3ClientOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
identity: IdentityData;
|
||||
nickname: string;
|
||||
serverPassword?: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
}
|
||||
|
||||
type ClientState =
|
||||
| "disconnected"
|
||||
| "init"
|
||||
| "handshake"
|
||||
| "connected"
|
||||
| "disconnecting";
|
||||
|
||||
export class Ts3Client extends EventEmitter {
|
||||
private socket: dgram.Socket | null = null;
|
||||
private state: ClientState = "disconnected";
|
||||
private opts!: Ts3ClientOptions;
|
||||
|
||||
// Packet counters (one per packet type for outgoing)
|
||||
private packetCounter = new Uint16Array(9);
|
||||
private generationCounter = new Uint32Array(9);
|
||||
// Incoming generation tracking
|
||||
private inGenerationCounter = new Uint32Array(9);
|
||||
|
||||
// Resend tracking
|
||||
private resendMap = new Map<number, ResendPacket>(); // packetId -> ResendPacket
|
||||
private initResend: ResendPacket | null = null;
|
||||
private resendTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastMessageTime = Date.now();
|
||||
|
||||
// Crypto state
|
||||
private cryptoInitComplete = false;
|
||||
private ivStruct: Buffer | null = null;
|
||||
private fakeSignature = Buffer.alloc(MAC_LEN, 0);
|
||||
private keyNonceCache = new Map<number, { key: Buffer; nonce: Buffer; gen: number }>();
|
||||
private alphaTmp: Buffer | null = null;
|
||||
|
||||
// Client ID assigned by server
|
||||
private clientId = 0;
|
||||
|
||||
// Fragment reassembly
|
||||
private fragmentBuffer: Buffer[] = [];
|
||||
private fragmenting = false;
|
||||
private fragmentFlags = 0;
|
||||
|
||||
// Channel map (populated during init sequence for auto-move)
|
||||
private channelMap = new Map<string, number>(); // channel_name → cid
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
getState(): ClientState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getClientId(): number {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
async connect(opts: Ts3ClientOptions): Promise<void> {
|
||||
this.opts = opts;
|
||||
this.state = "init";
|
||||
this.cryptoInitComplete = false;
|
||||
this.ivStruct = null;
|
||||
this.fakeSignature.fill(0);
|
||||
this.keyNonceCache.clear();
|
||||
this.resendMap.clear();
|
||||
this.initResend = null;
|
||||
this.packetCounter.fill(0);
|
||||
this.generationCounter.fill(0);
|
||||
this.inGenerationCounter.fill(0);
|
||||
this.fragmentBuffer = [];
|
||||
this.fragmenting = false;
|
||||
this.fragmentFlags = 0;
|
||||
this.clientId = 0;
|
||||
this.channelMap.clear();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = dgram.createSocket("udp4");
|
||||
|
||||
this.socket.on("message", (msg) => {
|
||||
try {
|
||||
this.handleIncomingPacket(msg);
|
||||
} catch (e) {
|
||||
this.emit("error", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("error", (err) => {
|
||||
this.emit("error", err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.socket.bind(0, () => {
|
||||
this.lastMessageTime = Date.now();
|
||||
|
||||
// Start resend timer (100ms interval)
|
||||
this.resendTimer = setInterval(() => this.resendLoop(), 100);
|
||||
// Ping timer starts after connection
|
||||
this.pingTimer = null;
|
||||
|
||||
// Increment command counter without actually sending (old clientinitiv behavior)
|
||||
this.incPacketCounter(PacketType.Command);
|
||||
|
||||
// Send Init0
|
||||
const init0 = this.buildInit0();
|
||||
this.sendInitPacket(init0);
|
||||
|
||||
// Set up connect timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.state !== "connected") {
|
||||
reject(new Error("Connection timeout"));
|
||||
this.disconnect();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
this.once("connected", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.once("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.state === "connected") {
|
||||
this.state = "disconnecting";
|
||||
const cmd = buildCommand("clientdisconnect", {
|
||||
reasonid: 8,
|
||||
reasonmsg: "leaving",
|
||||
});
|
||||
this.sendCommand(cmd);
|
||||
}
|
||||
setTimeout(() => this.cleanup(), 500);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.state = "disconnected";
|
||||
if (this.resendTimer) clearInterval(this.resendTimer);
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
this.resendTimer = null;
|
||||
this.pingTimer = null;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
this.emit("disconnected");
|
||||
}
|
||||
|
||||
// Send voice data (already Opus-encoded)
|
||||
sendVoice(opusData: Buffer): void {
|
||||
if (this.state !== "connected") return;
|
||||
const voiceBuf = Buffer.alloc(3 + opusData.length);
|
||||
voiceBuf[2] = 5; // Opus Music
|
||||
opusData.copy(voiceBuf, 3);
|
||||
this.sendOutgoing(voiceBuf, PacketType.Voice);
|
||||
}
|
||||
|
||||
// Send end-of-voice marker
|
||||
sendVoiceStop(): void {
|
||||
if (this.state !== "connected") return;
|
||||
const voiceBuf = Buffer.alloc(3);
|
||||
voiceBuf[2] = 5; // Opus Music
|
||||
this.sendOutgoing(voiceBuf, PacketType.Voice);
|
||||
}
|
||||
|
||||
sendCommand(cmd: string): void {
|
||||
this.sendOutgoing(Buffer.from(cmd, "utf-8"), PacketType.Command);
|
||||
}
|
||||
|
||||
// ====== Packet Building & Sending ======
|
||||
|
||||
private buildInit0(): Buffer {
|
||||
const buf = Buffer.alloc(4 + 1 + 4 + 4 + 8); // version(4) + step(1) + timestamp(4) + random(4) + reserved(8)
|
||||
buf.writeUInt32BE(INIT_VERSION, 0);
|
||||
buf[4] = 0x00; // step 0
|
||||
buf.writeUInt32BE(Math.floor(Date.now() / 1000), 5);
|
||||
crypto.randomFillSync(buf, 9, 4);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private sendInitPacket(data: Buffer): void {
|
||||
const raw = this.buildRawPacket(data, PacketType.Init1, 101, 0, FLAG_UNENCRYPTED);
|
||||
// Use fixed MAC for init packets
|
||||
INIT_MAC.copy(raw, 0, 0, MAC_LEN);
|
||||
this.initResend = {
|
||||
raw,
|
||||
packetId: 101,
|
||||
firstSend: Date.now(),
|
||||
lastSend: Date.now(),
|
||||
};
|
||||
this.sendRaw(raw);
|
||||
}
|
||||
|
||||
private sendOutgoing(
|
||||
data: Buffer,
|
||||
packetType: PacketType,
|
||||
extraFlags: number = 0
|
||||
): void {
|
||||
const { id, gen } = this.getPacketCounter(packetType);
|
||||
this.incPacketCounter(packetType);
|
||||
|
||||
let flags = extraFlags;
|
||||
|
||||
switch (packetType) {
|
||||
case PacketType.Voice:
|
||||
case PacketType.VoiceWhisper:
|
||||
// Write the voice packet ID into the first 2 bytes of data
|
||||
data.writeUInt16BE(id, 0);
|
||||
break;
|
||||
case PacketType.Command:
|
||||
case PacketType.CommandLow:
|
||||
flags |= FLAG_NEWPROTOCOL;
|
||||
break;
|
||||
case PacketType.Ping:
|
||||
flags |= FLAG_UNENCRYPTED;
|
||||
break;
|
||||
case PacketType.Pong:
|
||||
flags |= FLAG_UNENCRYPTED;
|
||||
break;
|
||||
case PacketType.Ack:
|
||||
case PacketType.AckLow:
|
||||
break;
|
||||
}
|
||||
|
||||
const raw = this.buildRawPacket(data, packetType, id, gen, flags);
|
||||
|
||||
// Encrypt
|
||||
this.encryptPacket(raw, packetType, id, gen, flags, data);
|
||||
|
||||
// Track for resend if needed
|
||||
if (
|
||||
packetType === PacketType.Command ||
|
||||
packetType === PacketType.CommandLow
|
||||
) {
|
||||
this.resendMap.set(id, {
|
||||
raw,
|
||||
packetId: id,
|
||||
firstSend: Date.now(),
|
||||
lastSend: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
this.sendRaw(raw);
|
||||
}
|
||||
|
||||
private buildRawPacket(
|
||||
data: Buffer,
|
||||
packetType: PacketType,
|
||||
packetId: number,
|
||||
_gen: number,
|
||||
flags: number
|
||||
): Buffer {
|
||||
const raw = Buffer.alloc(MAC_LEN + C2S_HEADER_LEN + data.length);
|
||||
// Header at offset MAC_LEN: PId(2) + CId(2) + PT(1)
|
||||
raw.writeUInt16BE(packetId, MAC_LEN);
|
||||
raw.writeUInt16BE(this.clientId, MAC_LEN + 2);
|
||||
raw[MAC_LEN + 4] = (flags & 0xf0) | (packetType & 0x0f);
|
||||
// Copy data
|
||||
data.copy(raw, MAC_LEN + C2S_HEADER_LEN);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private encryptPacket(
|
||||
raw: Buffer,
|
||||
packetType: PacketType,
|
||||
packetId: number,
|
||||
gen: number,
|
||||
flags: number,
|
||||
data: Buffer
|
||||
): void {
|
||||
if (packetType === PacketType.Init1) {
|
||||
INIT_MAC.copy(raw, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags & FLAG_UNENCRYPTED) {
|
||||
this.fakeSignature.copy(raw, 0, 0, MAC_LEN);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get key/nonce
|
||||
const useDummy = !this.cryptoInitComplete;
|
||||
let key: Buffer, nonce: Buffer;
|
||||
if (useDummy) {
|
||||
key = DUMMY_KEY;
|
||||
nonce = DUMMY_NONCE;
|
||||
} else {
|
||||
({ key, nonce } = this.getKeyNonce(false, packetId, gen, packetType));
|
||||
if (packetId <= 3) {
|
||||
this.emit("debug", `[ENCRYPT] ptype=${packetType} pid=${packetId} gen=${gen} key=${key.toString("hex")} nonce=${nonce.toString("hex")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Header for associated data
|
||||
const header = raw.subarray(MAC_LEN, MAC_LEN + C2S_HEADER_LEN);
|
||||
const { ciphertext, mac } = eaxEncrypt(key, nonce, header, data, MAC_LEN);
|
||||
|
||||
// Write MAC and ciphertext
|
||||
mac.copy(raw, 0);
|
||||
ciphertext.copy(raw, MAC_LEN + C2S_HEADER_LEN);
|
||||
}
|
||||
|
||||
private decryptPacket(
|
||||
raw: Buffer,
|
||||
packetType: PacketType,
|
||||
packetId: number,
|
||||
gen: number,
|
||||
flags: number
|
||||
): Buffer | null {
|
||||
if (packetType === PacketType.Init1) {
|
||||
// Verify init MAC
|
||||
if (!INIT_MAC.equals(raw.subarray(0, MAC_LEN))) return null;
|
||||
return raw.subarray(MAC_LEN + S2C_HEADER_LEN);
|
||||
}
|
||||
|
||||
if (flags & FLAG_UNENCRYPTED) {
|
||||
// Verify fake signature
|
||||
if (this.cryptoInitComplete) {
|
||||
if (!this.fakeSignature.equals(raw.subarray(0, MAC_LEN))) return null;
|
||||
}
|
||||
return raw.subarray(MAC_LEN + S2C_HEADER_LEN);
|
||||
}
|
||||
|
||||
// Try current key mode first
|
||||
const result = this.tryDecrypt(raw, packetType, packetId, gen, false);
|
||||
if (result) return result;
|
||||
|
||||
// Always try the other key mode as fallback
|
||||
const fallback = this.tryDecrypt(raw, packetType, packetId, gen, true);
|
||||
if (fallback) {
|
||||
this.emit("debug", `Decrypted type=${packetType} id=${packetId} with ${this.cryptoInitComplete ? "DUMMY" : "REAL"} key (fallback)`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryDecrypt(
|
||||
raw: Buffer,
|
||||
packetType: PacketType,
|
||||
packetId: number,
|
||||
gen: number,
|
||||
forceDummy: boolean
|
||||
): Buffer | null {
|
||||
const useDummy = forceDummy || !this.cryptoInitComplete;
|
||||
let key: Buffer, nonce: Buffer;
|
||||
if (useDummy) {
|
||||
key = DUMMY_KEY;
|
||||
nonce = DUMMY_NONCE;
|
||||
} else {
|
||||
({ key, nonce } = this.getKeyNonce(true, packetId, gen, packetType));
|
||||
}
|
||||
|
||||
const header = raw.subarray(MAC_LEN, MAC_LEN + S2C_HEADER_LEN);
|
||||
const mac = raw.subarray(0, MAC_LEN);
|
||||
const ciphertext = raw.subarray(MAC_LEN + S2C_HEADER_LEN);
|
||||
|
||||
return eaxDecrypt(key, nonce, header, ciphertext, mac, MAC_LEN);
|
||||
}
|
||||
|
||||
private getKeyNonce(
|
||||
fromServer: boolean,
|
||||
packetId: number,
|
||||
gen: number,
|
||||
packetType: PacketType
|
||||
): { key: Buffer; nonce: Buffer } {
|
||||
if (!this.ivStruct) {
|
||||
return { key: DUMMY_KEY, nonce: DUMMY_NONCE };
|
||||
}
|
||||
return deriveKeyNonce(fromServer, packetId, gen, packetType, this.ivStruct);
|
||||
}
|
||||
|
||||
private getPacketCounter(
|
||||
packetType: PacketType
|
||||
): { id: number; gen: number } {
|
||||
if (packetType === PacketType.Init1) return { id: 101, gen: 0 };
|
||||
return {
|
||||
id: this.packetCounter[packetType],
|
||||
gen: this.generationCounter[packetType],
|
||||
};
|
||||
}
|
||||
|
||||
private incPacketCounter(packetType: PacketType): void {
|
||||
if (packetType === PacketType.Init1) return;
|
||||
this.packetCounter[packetType]++;
|
||||
if (this.packetCounter[packetType] === 0) {
|
||||
this.generationCounter[packetType]++;
|
||||
}
|
||||
}
|
||||
|
||||
private sendRaw(raw: Buffer): void {
|
||||
if (!this.socket || this.state === "disconnected") return;
|
||||
const ptByte = raw[MAC_LEN + 4]; // C2S header: MAC(8) + PId(2) + CId(2) + PT(1)
|
||||
const ptype = ptByte & 0x0f;
|
||||
const pflags = ptByte & 0xf0;
|
||||
const pid = raw.readUInt16BE(MAC_LEN);
|
||||
this.emit("debug", `[OUT] type=${ptype} id=${pid} flags=0x${pflags.toString(16)} len=${raw.length}`);
|
||||
this.socket.send(raw, this.opts.port, this.opts.host);
|
||||
}
|
||||
|
||||
// ====== Incoming Packet Handling ======
|
||||
|
||||
private handleIncomingPacket(raw: Buffer): void {
|
||||
if (raw.length < MAC_LEN + S2C_HEADER_LEN) return;
|
||||
|
||||
// Parse S2C header: MAC(8) + PId(2) + PT(1)
|
||||
const packetId = raw.readUInt16BE(MAC_LEN);
|
||||
const ptByte = raw[MAC_LEN + 2];
|
||||
const packetType = (ptByte & 0x0f) as PacketType;
|
||||
const flags = ptByte & 0xf0;
|
||||
|
||||
this.lastMessageTime = Date.now();
|
||||
|
||||
// Decrypt
|
||||
const gen = this.inGenerationCounter[packetType] || 0;
|
||||
const data = this.decryptPacket(raw, packetType, packetId, gen, flags);
|
||||
if (!data) {
|
||||
this.emit("debug", `Failed to decrypt packet type=${packetType} id=${packetId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (packetType) {
|
||||
case PacketType.Init1:
|
||||
this.handleInit(data);
|
||||
break;
|
||||
case PacketType.Command:
|
||||
case PacketType.CommandLow:
|
||||
this.sendAck(packetId, packetType === PacketType.Command ? PacketType.Ack : PacketType.AckLow);
|
||||
this.handleCommandData(data, flags);
|
||||
break;
|
||||
case PacketType.Ack:
|
||||
this.handleAck(data);
|
||||
break;
|
||||
case PacketType.Ping:
|
||||
this.handlePing(packetId);
|
||||
break;
|
||||
case PacketType.Pong:
|
||||
// Pong received, good
|
||||
break;
|
||||
case PacketType.Voice:
|
||||
case PacketType.VoiceWhisper:
|
||||
this.emit("voice", data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sendAck(ackId: number, ackType: PacketType): void {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16BE(ackId);
|
||||
this.sendOutgoing(buf, ackType);
|
||||
}
|
||||
|
||||
private handlePing(packetId: number): void {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16BE(packetId);
|
||||
this.sendOutgoing(buf, PacketType.Pong);
|
||||
}
|
||||
|
||||
private handleAck(data: Buffer): void {
|
||||
if (data.length < 2) return;
|
||||
const ackedId = data.readUInt16BE(0);
|
||||
this.emit("debug", `ACK received for our packet id=${ackedId}`);
|
||||
this.resendMap.delete(ackedId);
|
||||
}
|
||||
|
||||
// ====== Init Handshake ======
|
||||
|
||||
private handleInit(data: Buffer): void {
|
||||
if (data.length < 1) return;
|
||||
const step = data[0];
|
||||
|
||||
this.emit("debug", `Init step ${step} received (${data.length} bytes)`);
|
||||
|
||||
switch (step) {
|
||||
case 1: {
|
||||
// Server response to Init0
|
||||
if (data.length < 21) {
|
||||
this.emit("debug", "Init1 too short");
|
||||
return;
|
||||
}
|
||||
// Build Init2: version(4) + step(1) + serverData(16) + reversedRandom(4)
|
||||
const init2 = Buffer.alloc(4 + 1 + 16 + 4);
|
||||
init2.writeUInt32BE(INIT_VERSION, 0);
|
||||
init2[4] = 0x02; // step 2
|
||||
data.subarray(1, 21).copy(init2, 5);
|
||||
this.initResend = null;
|
||||
this.sendInitPacket(init2);
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
// Server RSA puzzle
|
||||
if (data.length < 1 + 64 + 64 + 4 + 100) {
|
||||
this.emit("debug", "Init3 too short");
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = "handshake";
|
||||
this.initResend = null;
|
||||
|
||||
const x = data.subarray(1, 65);
|
||||
const n = data.subarray(65, 129);
|
||||
const level = data.readInt32BE(129);
|
||||
const serverData = data.subarray(133, 233);
|
||||
|
||||
this.emit("debug", `RSA puzzle level=${level}`);
|
||||
|
||||
// Solve RSA puzzle: y = x^(2^level) mod n
|
||||
const xBig = BigInt("0x" + x.toString("hex"));
|
||||
const nBig = BigInt("0x" + n.toString("hex"));
|
||||
const exp = 1n << BigInt(level); // 2^level
|
||||
const yBig = modPow(xBig, exp, nBig);
|
||||
|
||||
let yHex = yBig.toString(16);
|
||||
while (yHex.length < 128) yHex = "0" + yHex;
|
||||
const y = Buffer.from(yHex, "hex");
|
||||
|
||||
// Generate alpha (10 random bytes)
|
||||
this.alphaTmp = crypto.randomBytes(10);
|
||||
const alpha = this.alphaTmp.toString("base64");
|
||||
const omega = this.opts.identity.publicKeyString;
|
||||
|
||||
const initAdd = buildCommand("clientinitiv", {
|
||||
alpha,
|
||||
omega,
|
||||
ot: 1,
|
||||
ip: "",
|
||||
});
|
||||
const textBytes = Buffer.from(initAdd, "utf-8");
|
||||
|
||||
// Build Init4
|
||||
const init4 = Buffer.alloc(
|
||||
4 + 1 + 64 + 64 + 4 + 100 + 64 + textBytes.length
|
||||
);
|
||||
init4.writeUInt32BE(INIT_VERSION, 0);
|
||||
init4[4] = 0x04; // step 4
|
||||
x.copy(init4, 5);
|
||||
n.copy(init4, 69);
|
||||
init4.writeInt32BE(level, 133);
|
||||
serverData.copy(init4, 137);
|
||||
y.copy(init4, 237);
|
||||
textBytes.copy(init4, 301);
|
||||
|
||||
this.sendInitPacket(init4);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x7f: {
|
||||
// Server wants us to restart
|
||||
this.emit("debug", "Server requested restart, sending Init0 again");
|
||||
this.initResend = null;
|
||||
const init0 = this.buildInit0();
|
||||
this.sendInitPacket(init0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCommandData(data: Buffer, flags: number): void {
|
||||
// Handle fragmented packets
|
||||
if (flags & FLAG_FRAGMENTED) {
|
||||
if (!this.fragmenting) {
|
||||
// Start of fragment
|
||||
this.fragmenting = true;
|
||||
this.fragmentFlags = flags;
|
||||
this.fragmentBuffer = [Buffer.from(data)];
|
||||
} else {
|
||||
// End of fragment
|
||||
this.fragmentBuffer.push(Buffer.from(data));
|
||||
const merged = Buffer.concat(this.fragmentBuffer);
|
||||
const savedFlags = this.fragmentFlags;
|
||||
this.fragmenting = false;
|
||||
this.fragmentFlags = 0;
|
||||
this.fragmentBuffer = [];
|
||||
this.processCommand(merged, savedFlags);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fragmenting) {
|
||||
// Middle fragment
|
||||
this.fragmentBuffer.push(Buffer.from(data));
|
||||
return;
|
||||
}
|
||||
|
||||
this.processCommand(data, flags);
|
||||
}
|
||||
|
||||
private processCommand(data: Buffer, flags: number = 0): void {
|
||||
// Decompress if needed
|
||||
if (flags & FLAG_COMPRESSED) {
|
||||
try {
|
||||
data = qlzDecompress(data);
|
||||
} catch (e: any) {
|
||||
this.emit("debug", `Decompression failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cmdStr = data.toString("utf-8");
|
||||
this.emit("debug", `[CMD IN] ${cmdStr.substring(0, 200)}`);
|
||||
|
||||
const parsed = parseCommand(cmdStr);
|
||||
this.emit("command", parsed);
|
||||
|
||||
switch (parsed.name) {
|
||||
case "initivexpand":
|
||||
this.handleInitIvExpand(parsed.params);
|
||||
break;
|
||||
case "initivexpand2":
|
||||
this.handleInitIvExpand2(parsed.params);
|
||||
break;
|
||||
case "initserver":
|
||||
this.handleInitServer(parsed.params);
|
||||
break;
|
||||
case "channellist":
|
||||
this.handleChannelList(parsed);
|
||||
break;
|
||||
case "channellistfinished":
|
||||
this.handleChannelListFinished();
|
||||
break;
|
||||
case "notifyclientleftview":
|
||||
if (
|
||||
parsed.params.clid &&
|
||||
parseInt(parsed.params.clid) === this.clientId
|
||||
) {
|
||||
this.cleanup();
|
||||
}
|
||||
break;
|
||||
case "notifytextmessage":
|
||||
this.emit("textMessage", parsed.params);
|
||||
break;
|
||||
case "error":
|
||||
this.emit("ts3error", parsed.params);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Crypto Init (Old Protocol - initivexpand) ======
|
||||
|
||||
private handleInitIvExpand(params: Record<string, string>): void {
|
||||
this.initResend = null;
|
||||
this.emit("debug", "Processing initivexpand (old protocol)");
|
||||
|
||||
const { alpha, beta, omega } = params;
|
||||
if (!alpha || !beta || !omega) {
|
||||
this.emit("error", new Error("Missing initivexpand parameters"));
|
||||
return;
|
||||
}
|
||||
|
||||
const alphaBytes = Buffer.from(alpha, "base64");
|
||||
const betaBytes = Buffer.from(beta, "base64");
|
||||
const omegaBytes = Buffer.from(omega, "base64");
|
||||
|
||||
const sharedKey = getSharedSecret(this.opts.identity.privateKey, omegaBytes);
|
||||
|
||||
this.ivStruct = Buffer.alloc(10 + betaBytes.length);
|
||||
xorInto(this.ivStruct, sharedKey, 10);
|
||||
xorInto(this.ivStruct, Buffer.from(this.alphaTmp!), 10);
|
||||
const betaPart = this.ivStruct.subarray(10);
|
||||
for (let i = 0; i < betaBytes.length; i++) {
|
||||
betaPart[i] = sharedKey[10 + i] ^ betaBytes[i];
|
||||
}
|
||||
|
||||
const sig = sha1(this.ivStruct);
|
||||
sig.copy(this.fakeSignature, 0, 0, MAC_LEN);
|
||||
|
||||
this.cryptoInitComplete = true;
|
||||
this.alphaTmp = null;
|
||||
|
||||
this.sendClientInit();
|
||||
}
|
||||
|
||||
// ====== Crypto Init (New Protocol - initivexpand2) ======
|
||||
|
||||
private async handleInitIvExpand2(params: Record<string, string>): Promise<void> {
|
||||
if (this.cryptoInitComplete || !this.alphaTmp) {
|
||||
this.emit("debug", "Ignoring duplicate initivexpand2");
|
||||
return;
|
||||
}
|
||||
this.initResend = null;
|
||||
this.emit("debug", "Processing initivexpand2 (new protocol)");
|
||||
|
||||
const { l: license, beta, omega, proof } = params;
|
||||
if (!license || !beta || !omega) {
|
||||
this.emit("error", new Error("Missing initivexpand2 parameters"));
|
||||
return;
|
||||
}
|
||||
|
||||
const betaBytes = Buffer.from(beta, "base64");
|
||||
const licenseBytes = Buffer.from(license, "base64");
|
||||
|
||||
this.emit("debug", "Skipping proof verification");
|
||||
|
||||
try {
|
||||
this.emit("debug", `License bytes (${licenseBytes.length}): ${licenseBytes.toString("hex").substring(0, 64)}...`);
|
||||
const blocks = parseLicense(licenseBytes);
|
||||
this.emit("debug", `Parsed ${blocks.length} license blocks`);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
this.emit("debug", ` Block ${i}: type=${blocks[i].type} key=${blocks[i].key.toString("hex").substring(0, 16)}... hash=${blocks[i].hash.toString("hex").substring(0, 16)}...`);
|
||||
}
|
||||
const serverKey = await deriveLicenseKey(blocks);
|
||||
this.emit("debug", `Derived server key: ${serverKey.toString("hex")}`);
|
||||
|
||||
const { publicKey: tempPub, privateKey: tempPriv } = await generateTemporaryKey();
|
||||
|
||||
const ekBase64 = tempPub.toString("base64");
|
||||
const toSign = Buffer.concat([tempPub, betaBytes]);
|
||||
const signBuf = crypto.sign("SHA256", toSign, this.opts.identity.privateKey);
|
||||
const proofBase64 = signBuf.toString("base64");
|
||||
const clientEkCmd = buildCommand("clientek", {
|
||||
ek: ekBase64,
|
||||
proof: proofBase64,
|
||||
});
|
||||
this.emit("debug", `Sending clientek (${clientEkCmd.length} chars): ${clientEkCmd.substring(0, 100)}...`);
|
||||
this.sendCommand(clientEkCmd);
|
||||
|
||||
const sharedData = await getSharedSecret2(serverKey, tempPriv);
|
||||
this.emit("debug", `SharedSecret2 first16: ${sharedData.subarray(0, 16).toString("hex")}`);
|
||||
|
||||
this.ivStruct = Buffer.from(sharedData);
|
||||
this.emit("debug", `Alpha: ${this.alphaTmp!.toString("hex")}`);
|
||||
this.emit("debug", `Beta (${betaBytes.length}): ${betaBytes.subarray(0, 16).toString("hex")}...`);
|
||||
xorInto(this.ivStruct, this.alphaTmp!, 10);
|
||||
xorInto(this.ivStruct.subarray(10), betaBytes, betaBytes.length);
|
||||
|
||||
this.emit("debug", `ivStruct first16: ${this.ivStruct.subarray(0, 16).toString("hex")}`);
|
||||
|
||||
const sig = sha1(this.ivStruct);
|
||||
sig.copy(this.fakeSignature, 0, 0, MAC_LEN);
|
||||
this.emit("debug", `FakeSignature: ${this.fakeSignature.toString("hex")}`);
|
||||
|
||||
this.cryptoInitComplete = true;
|
||||
this.alphaTmp = null;
|
||||
|
||||
this.sendClientInit();
|
||||
} catch (e) {
|
||||
this.emit("error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Post-Handshake ======
|
||||
|
||||
private sendClientInit(): void {
|
||||
this.emit("debug", "Sending clientinit");
|
||||
|
||||
const identity = this.opts.identity;
|
||||
const cmd = buildCommand("clientinit", {
|
||||
client_nickname: this.opts.nickname,
|
||||
client_version: VERSION_SIGN.version,
|
||||
client_platform: VERSION_SIGN.platform,
|
||||
client_input_hardware: 1,
|
||||
client_output_hardware: 1,
|
||||
client_default_channel: this.opts.defaultChannel || "",
|
||||
client_default_channel_password: this.opts.channelPassword
|
||||
? hashPassword(this.opts.channelPassword)
|
||||
: "",
|
||||
client_server_password: this.opts.serverPassword
|
||||
? hashPassword(this.opts.serverPassword)
|
||||
: "",
|
||||
client_meta_data: "",
|
||||
client_version_sign: VERSION_SIGN.sign,
|
||||
client_key_offset: identity.keyOffset.toString(),
|
||||
client_nickname_phonetic: "",
|
||||
client_default_token: "",
|
||||
hwid: "",
|
||||
});
|
||||
|
||||
this.sendCommand(cmd);
|
||||
}
|
||||
|
||||
private handleInitServer(params: Record<string, string>): void {
|
||||
this.clientId = parseInt(params.aclid) || 0;
|
||||
this.emit("debug", `Connected! ClientId=${this.clientId}`);
|
||||
|
||||
this.state = "connected";
|
||||
|
||||
// Start ping timer (every 1 second)
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.state === "connected") {
|
||||
this.sendOutgoing(Buffer.alloc(0), PacketType.Ping);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.emit("connected");
|
||||
}
|
||||
|
||||
private handleChannelList(parsed: ParsedCommand): void {
|
||||
// Each channellist message may contain one or multiple channel entries (pipe-separated)
|
||||
const entries = parsed.groups ?? [parsed.params];
|
||||
for (const entry of entries) {
|
||||
const cid = parseInt(entry.cid);
|
||||
const name = entry.channel_name;
|
||||
if (cid && name) {
|
||||
this.channelMap.set(name, cid);
|
||||
}
|
||||
}
|
||||
this.emit("debug", `channellist: +${entries.length} channels (total: ${this.channelMap.size})`);
|
||||
}
|
||||
|
||||
private handleChannelListFinished(): void {
|
||||
this.emit("debug", `Channel list received: ${this.channelMap.size} channel(s)`);
|
||||
|
||||
// Auto-move to defaultChannel if configured
|
||||
const target = this.opts.defaultChannel;
|
||||
if (!target || !this.clientId) return;
|
||||
|
||||
// Support both channel ID (numeric) and channel name
|
||||
let cid: number | undefined;
|
||||
const asNum = parseInt(target);
|
||||
if (!isNaN(asNum) && String(asNum) === target) {
|
||||
// Numeric value → treat as channel ID directly
|
||||
cid = asNum;
|
||||
} else {
|
||||
// String value → look up by channel name
|
||||
cid = this.channelMap.get(target);
|
||||
}
|
||||
|
||||
if (!cid) {
|
||||
this.emit("debug", `Default channel "${target}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const moveCmd = buildCommand("clientmove", {
|
||||
cid,
|
||||
clid: this.clientId,
|
||||
cpw: this.opts.channelPassword ?? "",
|
||||
});
|
||||
this.emit("debug", `Moving to channel "${target}" (cid=${cid})`);
|
||||
this.sendCommand(moveCmd);
|
||||
}
|
||||
|
||||
// ====== Resend Loop ======
|
||||
|
||||
private resendLoop(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Check init resend
|
||||
if (this.initResend) {
|
||||
if (now - this.initResend.firstSend > 30000) {
|
||||
this.emit("error", new Error("Init timeout"));
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
if (now - this.initResend.lastSend > 1000) {
|
||||
this.initResend.lastSend = now;
|
||||
this.sendRaw(this.initResend.raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Check command resends
|
||||
for (const [id, pkt] of this.resendMap) {
|
||||
if (now - pkt.firstSend > 30000) {
|
||||
this.emit("error", new Error(`Packet ${id} timeout`));
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
if (now - pkt.lastSend > 1000) {
|
||||
pkt.lastSend = now;
|
||||
this.sendRaw(pkt.raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Check overall timeout
|
||||
if (now - this.lastMessageTime > 30000) {
|
||||
this.emit("error", new Error("Connection timeout - no response"));
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modular exponentiation for RSA puzzle
|
||||
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
|
||||
if (mod === 1n) return 0n;
|
||||
let result = 1n;
|
||||
base = base % mod;
|
||||
while (exp > 0n) {
|
||||
if (exp & 1n) {
|
||||
result = (result * base) % mod;
|
||||
}
|
||||
exp >>= 1n;
|
||||
base = (base * base) % mod;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// TS3 command format: "commandname key=value key=value|key=value"
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"\\": "\\\\",
|
||||
"/": "\\/",
|
||||
" ": "\\s",
|
||||
"|": "\\p",
|
||||
"\f": "\\f",
|
||||
"\n": "\\n",
|
||||
"\r": "\\r",
|
||||
"\t": "\\t",
|
||||
"\v": "\\v",
|
||||
};
|
||||
|
||||
const UNESCAPE_MAP: Record<string, string> = {
|
||||
"\\": "\\",
|
||||
"/": "/",
|
||||
s: " ",
|
||||
p: "|",
|
||||
f: "\f",
|
||||
n: "\n",
|
||||
r: "\r",
|
||||
t: "\t",
|
||||
v: "\v",
|
||||
};
|
||||
|
||||
export function tsEscape(str: string): string {
|
||||
let result = "";
|
||||
for (const ch of str) {
|
||||
result += ESCAPE_MAP[ch] ?? ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function tsUnescape(str: string): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === "\\") {
|
||||
i++;
|
||||
if (i >= str.length) throw new Error("Invalid escape sequence");
|
||||
const mapped = UNESCAPE_MAP[str[i]];
|
||||
if (mapped === undefined)
|
||||
throw new Error(`Unknown escape: \\${str[i]}`);
|
||||
result += mapped;
|
||||
} else {
|
||||
result += str[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ParsedCommand {
|
||||
name: string;
|
||||
params: Record<string, string>;
|
||||
groups?: Record<string, string>[]; // For pipe-separated multi-params
|
||||
}
|
||||
|
||||
export function buildCommand(
|
||||
name: string,
|
||||
params: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
let cmd = tsEscape(name);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
const strVal =
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? "1"
|
||||
: "0"
|
||||
: String(value);
|
||||
cmd += ` ${key}=${tsEscape(strVal)}`;
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function parseCommand(raw: string): ParsedCommand {
|
||||
const parts = raw.split("|");
|
||||
const firstPart = parts[0].trim();
|
||||
const tokens = firstPart.split(" ");
|
||||
const name = tsUnescape(tokens[0]);
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const eqIdx = tokens[i].indexOf("=");
|
||||
if (eqIdx >= 0) {
|
||||
const key = tokens[i].substring(0, eqIdx);
|
||||
const value = tsUnescape(tokens[i].substring(eqIdx + 1));
|
||||
params[key] = value;
|
||||
} else {
|
||||
params[tokens[i]] = "";
|
||||
}
|
||||
}
|
||||
|
||||
let groups: Record<string, string>[] | undefined;
|
||||
if (parts.length > 1) {
|
||||
groups = [params];
|
||||
for (let g = 1; g < parts.length; g++) {
|
||||
const gTokens = parts[g].trim().split(" ");
|
||||
const gParams: Record<string, string> = {};
|
||||
for (const token of gTokens) {
|
||||
const eqIdx = token.indexOf("=");
|
||||
if (eqIdx >= 0) {
|
||||
gParams[token.substring(0, eqIdx)] = tsUnescape(
|
||||
token.substring(eqIdx + 1)
|
||||
);
|
||||
} else {
|
||||
gParams[token] = "";
|
||||
}
|
||||
}
|
||||
groups.push(gParams);
|
||||
}
|
||||
}
|
||||
|
||||
return { name, params, groups };
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as crypto from "crypto";
|
||||
|
||||
// AES-CMAC (OMAC1) implementation
|
||||
const BLOCK_SIZE = 16;
|
||||
const ZERO_BLOCK = Buffer.alloc(BLOCK_SIZE, 0);
|
||||
const RB = 0x87; // Constant for GF(2^128) multiplication
|
||||
|
||||
function doubleBlock(buf: Buffer): Buffer {
|
||||
const out = Buffer.alloc(BLOCK_SIZE);
|
||||
let carry = 0;
|
||||
for (let i = BLOCK_SIZE - 1; i >= 0; i--) {
|
||||
const tmp = (buf[i] << 1) | carry;
|
||||
out[i] = tmp & 0xff;
|
||||
carry = (buf[i] >> 7) & 1;
|
||||
}
|
||||
if (carry) out[BLOCK_SIZE - 1] ^= RB;
|
||||
return out;
|
||||
}
|
||||
|
||||
function aesEncryptBlock(key: Buffer, block: Buffer): Buffer {
|
||||
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
|
||||
cipher.setAutoPadding(false);
|
||||
return Buffer.concat([cipher.update(block), cipher.final()]);
|
||||
}
|
||||
|
||||
function cmac(key: Buffer, data: Buffer): Buffer {
|
||||
// Generate subkeys
|
||||
const L = aesEncryptBlock(key, ZERO_BLOCK);
|
||||
const K1 = doubleBlock(L);
|
||||
const K2 = doubleBlock(K1);
|
||||
|
||||
const n = data.length;
|
||||
const numBlocks = n === 0 ? 1 : Math.ceil(n / BLOCK_SIZE);
|
||||
const lastBlockComplete = n > 0 && n % BLOCK_SIZE === 0;
|
||||
|
||||
// Process all blocks except the last
|
||||
let x: Buffer = Buffer.alloc(BLOCK_SIZE, 0);
|
||||
for (let i = 0; i < numBlocks - 1; i++) {
|
||||
const block = Buffer.from(data.subarray(i * BLOCK_SIZE, (i + 1) * BLOCK_SIZE));
|
||||
const y = xorBuffers(x, block);
|
||||
x = aesEncryptBlock(key, y);
|
||||
}
|
||||
|
||||
// Process last block
|
||||
let lastBlock: Buffer;
|
||||
if (lastBlockComplete) {
|
||||
lastBlock = xorBuffers(Buffer.from(data.subarray((numBlocks - 1) * BLOCK_SIZE)), K1);
|
||||
} else {
|
||||
const padded = Buffer.alloc(BLOCK_SIZE, 0);
|
||||
const remaining = data.subarray((numBlocks - 1) * BLOCK_SIZE);
|
||||
remaining.copy(padded);
|
||||
padded[remaining.length] = 0x80;
|
||||
lastBlock = xorBuffers(padded, K2);
|
||||
}
|
||||
|
||||
const y = xorBuffers(x, lastBlock);
|
||||
return aesEncryptBlock(key, y);
|
||||
}
|
||||
|
||||
// EAX mode implementation (AES-128-EAX)
|
||||
function eaxOmac(key: Buffer, tag: number, data: Buffer): Buffer {
|
||||
const tagBlock = Buffer.alloc(BLOCK_SIZE, 0);
|
||||
tagBlock[BLOCK_SIZE - 1] = tag;
|
||||
const input = Buffer.concat([tagBlock, data]);
|
||||
return cmac(key, input);
|
||||
}
|
||||
|
||||
export function eaxEncrypt(
|
||||
key: Buffer,
|
||||
nonce: Buffer,
|
||||
header: Buffer,
|
||||
plaintext: Buffer,
|
||||
macLen: number = 8
|
||||
): { ciphertext: Buffer; mac: Buffer } {
|
||||
const N = eaxOmac(key, 0, nonce);
|
||||
const H = eaxOmac(key, 1, header);
|
||||
const cipher = crypto.createCipheriv("aes-128-ctr", key, N);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const C = eaxOmac(key, 2, ciphertext);
|
||||
const tag = xorBuffers(xorBuffers(N, H), C);
|
||||
return { ciphertext, mac: tag.subarray(0, macLen) };
|
||||
}
|
||||
|
||||
export function eaxDecrypt(
|
||||
key: Buffer,
|
||||
nonce: Buffer,
|
||||
header: Buffer,
|
||||
ciphertext: Buffer,
|
||||
mac: Buffer,
|
||||
macLen: number = 8
|
||||
): Buffer | null {
|
||||
const N = eaxOmac(key, 0, nonce);
|
||||
const H = eaxOmac(key, 1, header);
|
||||
const C = eaxOmac(key, 2, ciphertext);
|
||||
const tag = xorBuffers(xorBuffers(N, H), C);
|
||||
if (!crypto.timingSafeEqual(tag.subarray(0, macLen), mac.subarray(0, macLen))) {
|
||||
return null;
|
||||
}
|
||||
const decipher = crypto.createDecipheriv("aes-128-ctr", key, N);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
// Hash helpers
|
||||
export function sha1(data: Buffer): Buffer {
|
||||
return crypto.createHash("sha1").update(data).digest();
|
||||
}
|
||||
|
||||
export function sha256(data: Buffer): Buffer {
|
||||
return crypto.createHash("sha256").update(data).digest();
|
||||
}
|
||||
|
||||
export function sha512(data: Buffer): Buffer {
|
||||
return crypto.createHash("sha512").update(data).digest();
|
||||
}
|
||||
|
||||
export function xorBuffers(a: Buffer, b: Buffer): Buffer {
|
||||
const len = Math.min(a.length, b.length);
|
||||
const out = Buffer.alloc(len);
|
||||
for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// XOR b into a (in-place, first `len` bytes)
|
||||
export function xorInto(a: Buffer, b: Buffer, len: number): void {
|
||||
for (let i = 0; i < len; i++) a[i] ^= b[i];
|
||||
}
|
||||
|
||||
export const MAC_LEN = 8;
|
||||
export const INIT_MAC = Buffer.from("TS3INIT1", "ascii");
|
||||
// "c:\windows\system\firewall32.cpl" split into key(16) + nonce(16)
|
||||
export const DUMMY_KEY = Buffer.from("c:\\windows\\syste", "ascii");
|
||||
export const DUMMY_NONCE = Buffer.from("m\\firewall32.cpl", "ascii");
|
||||
export const INIT_VERSION = 1566914096; // 3.5.0 [Stable]
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
if (!password) return "";
|
||||
return sha1(Buffer.from(password, "utf-8")).toString("base64");
|
||||
}
|
||||
|
||||
// Per-packet key/nonce derivation
|
||||
export function deriveKeyNonce(
|
||||
fromServer: boolean,
|
||||
packetId: number,
|
||||
generationId: number,
|
||||
packetType: number,
|
||||
ivStruct: Buffer
|
||||
): { key: Buffer; nonce: Buffer } {
|
||||
const tmpLen = ivStruct.length === 20 ? 26 : 70;
|
||||
const tmp = Buffer.alloc(tmpLen);
|
||||
tmp[0] = fromServer ? 0x30 : 0x31;
|
||||
tmp[1] = packetType;
|
||||
tmp.writeUInt32BE(generationId, 2);
|
||||
ivStruct.copy(tmp, 6);
|
||||
|
||||
const keynonce = sha256(tmp);
|
||||
const key = Buffer.from(keynonce.subarray(0, 16));
|
||||
const nonce = Buffer.from(keynonce.subarray(16, 32));
|
||||
key[0] ^= (packetId >> 8) & 0xff;
|
||||
key[1] ^= packetId & 0xff;
|
||||
return { key, nonce };
|
||||
}
|
||||
|
||||
// ECDSA sign with P-256 / SHA-256
|
||||
export function ecdsaSign(privateKeyDer: Buffer, data: Buffer): Buffer {
|
||||
const key = crypto.createPrivateKey({
|
||||
key: privateKeyDer,
|
||||
format: "der",
|
||||
type: "sec1",
|
||||
});
|
||||
return crypto.sign("SHA256", data, key);
|
||||
}
|
||||
|
||||
// ECDSA verify with P-256 / SHA-256
|
||||
export function ecdsaVerify(
|
||||
publicKeyDer: Buffer,
|
||||
data: Buffer,
|
||||
signature: Buffer
|
||||
): boolean {
|
||||
const key = crypto.createPublicKey({
|
||||
key: publicKeyDer,
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
return crypto.verify("SHA256", data, key, signature);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import * as crypto from "crypto";
|
||||
import { sha1, xorInto } from "./crypto.js";
|
||||
|
||||
// Static obfuscation key used by TS3 identity export
|
||||
const OBFUSCATION_KEY = Buffer.from(
|
||||
"b9dfaa7bee6ac57ac7b65f1094a1c155" +
|
||||
"e747327bc2fe5d51c512023fe54a2802" +
|
||||
"01004e90ad1daaae1075d53b7d571c30" +
|
||||
"e063b5a62a4a017bb394833aa0983e6e",
|
||||
"hex"
|
||||
);
|
||||
|
||||
export interface IdentityData {
|
||||
privateKey: crypto.KeyObject; // ECDSA private key (P-256)
|
||||
publicKey: crypto.KeyObject; // ECDSA public key (P-256)
|
||||
privateKeyBigInt: bigint; // Raw private key scalar
|
||||
publicKeyString: string; // Base64 of libtomcrypt-style public key export
|
||||
keyOffset: bigint;
|
||||
uid: string; // base64(sha1(publicKeyString))
|
||||
}
|
||||
|
||||
// Restore identity from serialized JSON (KeyObjects reconstructed from privateKeyBigInt)
|
||||
export function restoreIdentity(data: {
|
||||
privateKeyBigInt: string | bigint;
|
||||
keyOffset: string | bigint;
|
||||
publicKeyString: string;
|
||||
uid: string;
|
||||
}): IdentityData {
|
||||
const privScalar = typeof data.privateKeyBigInt === 'bigint'
|
||||
? data.privateKeyBigInt
|
||||
: BigInt(data.privateKeyBigInt);
|
||||
const keyOffset = typeof data.keyOffset === 'bigint'
|
||||
? data.keyOffset
|
||||
: BigInt(data.keyOffset);
|
||||
|
||||
const dBuf = bigintToBuffer32(privScalar);
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
ecdh.setPrivateKey(dBuf);
|
||||
const pub = ecdh.getPublicKey();
|
||||
const xBuf = pub.subarray(1, 33);
|
||||
const yBuf = pub.subarray(33, 65);
|
||||
|
||||
const jwk = {
|
||||
kty: "EC" as const,
|
||||
crv: "P-256" as const,
|
||||
x: Buffer.from(xBuf).toString("base64url"),
|
||||
y: Buffer.from(yBuf).toString("base64url"),
|
||||
d: dBuf.toString("base64url"),
|
||||
};
|
||||
|
||||
const privateKey = crypto.createPrivateKey({ key: jwk, format: "jwk" });
|
||||
const publicKey = crypto.createPublicKey(privateKey);
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
privateKeyBigInt: privScalar,
|
||||
publicKeyString: data.publicKeyString,
|
||||
keyOffset,
|
||||
uid: data.uid,
|
||||
};
|
||||
}
|
||||
|
||||
// Import identity from TS3 client export format: "0V<base64data>"
|
||||
export function fromTsIdentity(identityStr: string): IdentityData {
|
||||
const match = identityStr.match(/^(\d+)V([\w/+=]+)$/);
|
||||
if (!match) throw new Error("Invalid TS3 identity format");
|
||||
|
||||
const keyOffset = BigInt(match[1]);
|
||||
const ident = Buffer.from(match[2], "base64");
|
||||
if (ident.length < 20) throw new Error("Identity too short");
|
||||
|
||||
// Find null terminator starting at offset 20
|
||||
let nullIdx = -1;
|
||||
for (let i = 20; i < ident.length; i++) {
|
||||
if (ident[i] === 0) {
|
||||
nullIdx = i - 20;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hashLen = nullIdx < 0 ? ident.length - 20 : nullIdx;
|
||||
const hash = sha1(ident.subarray(20, 20 + hashLen));
|
||||
|
||||
// Deobfuscate: XOR first 20 bytes with hash
|
||||
xorInto(ident, hash, 20);
|
||||
// XOR up to 100 bytes with obfuscation key
|
||||
xorInto(ident, OBFUSCATION_KEY, Math.min(100, ident.length));
|
||||
|
||||
// The result is base64-encoded ASN.1 DER data (as UTF-8 bytes)
|
||||
const innerB64 = ident.toString("utf-8").replace(/\0.*$/, "");
|
||||
const asnData = Buffer.from(innerB64, "base64");
|
||||
|
||||
return importKeyFromAsn(asnData, keyOffset);
|
||||
}
|
||||
|
||||
// Import from raw base64 ASN.1 key (libtomcrypt format)
|
||||
export function fromBase64Key(
|
||||
base64Key: string,
|
||||
keyOffset: bigint = 0n
|
||||
): IdentityData {
|
||||
const asnData = Buffer.from(base64Key, "base64");
|
||||
return importKeyFromAsn(asnData, keyOffset);
|
||||
}
|
||||
|
||||
// Parse libtomcrypt-style ASN.1 key: SEQUENCE { BIT STRING, INTEGER(32), INTEGER(x), INTEGER(y) [, INTEGER(d)] }
|
||||
function importKeyFromAsn(
|
||||
asnData: Buffer,
|
||||
keyOffset: bigint
|
||||
): IdentityData {
|
||||
const parsed = parseDerSequence(asnData);
|
||||
if (parsed.length < 3) throw new Error("Invalid ASN.1 key data");
|
||||
|
||||
const bitInfo = parsed[0] as number;
|
||||
let privateKeyScalar: bigint | null = null;
|
||||
let pubX: bigint;
|
||||
let pubY: bigint;
|
||||
|
||||
if (bitInfo === 0x00 || bitInfo === 0x80) {
|
||||
pubX = parsed[2] as bigint;
|
||||
pubY = parsed[3] as bigint;
|
||||
if (bitInfo === 0x80 && parsed.length >= 5) {
|
||||
privateKeyScalar = parsed[4] as bigint;
|
||||
}
|
||||
} else if (bitInfo === 0xc0) {
|
||||
privateKeyScalar = parsed[2] as bigint;
|
||||
pubX = 0n;
|
||||
pubY = 0n;
|
||||
} else {
|
||||
throw new Error(`Unknown key bitInfo: 0x${bitInfo.toString(16)}`);
|
||||
}
|
||||
|
||||
if (privateKeyScalar === null) {
|
||||
throw new Error("Key does not contain a private key");
|
||||
}
|
||||
|
||||
let ecPrivKey: crypto.KeyObject;
|
||||
let ecPubKey: crypto.KeyObject;
|
||||
|
||||
if (pubX !== 0n || pubY !== 0n) {
|
||||
const dBuf = bigintToBuffer32(privateKeyScalar);
|
||||
const xBuf = bigintToBuffer32(pubX);
|
||||
const yBuf = bigintToBuffer32(pubY);
|
||||
|
||||
const jwk = {
|
||||
kty: "EC",
|
||||
crv: "P-256",
|
||||
x: xBuf.toString("base64url"),
|
||||
y: yBuf.toString("base64url"),
|
||||
d: dBuf.toString("base64url"),
|
||||
};
|
||||
ecPrivKey = crypto.createPrivateKey({ key: jwk, format: "jwk" });
|
||||
ecPubKey = crypto.createPublicKey(ecPrivKey);
|
||||
} else {
|
||||
const privKeyBuf = bigintToBuffer32(privateKeyScalar);
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
ecdh.setPrivateKey(privKeyBuf);
|
||||
const pub = ecdh.getPublicKey();
|
||||
const xBuf = pub.subarray(1, 33);
|
||||
const yBuf = pub.subarray(33, 65);
|
||||
const jwk = {
|
||||
kty: "EC",
|
||||
crv: "P-256",
|
||||
x: Buffer.from(xBuf).toString("base64url"),
|
||||
y: Buffer.from(yBuf).toString("base64url"),
|
||||
d: bigintToBuffer32(privateKeyScalar).toString("base64url"),
|
||||
};
|
||||
ecPrivKey = crypto.createPrivateKey({ key: jwk, format: "jwk" });
|
||||
ecPubKey = crypto.createPublicKey(ecPrivKey);
|
||||
}
|
||||
|
||||
const pubKeyString = exportPublicKeyString(ecPubKey);
|
||||
const uid = sha1(Buffer.from(pubKeyString, "ascii")).toString("base64");
|
||||
|
||||
return {
|
||||
privateKey: ecPrivKey,
|
||||
publicKey: ecPubKey,
|
||||
privateKeyBigInt: privateKeyScalar,
|
||||
publicKeyString: pubKeyString,
|
||||
keyOffset,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a new random TS3 identity
|
||||
export function generateIdentity(securityLevel: number = 8): IdentityData {
|
||||
const { privateKey: ecPrivKey, publicKey: ecPubKey } =
|
||||
crypto.generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
});
|
||||
const jwk = ecPrivKey.export({ format: "jwk" }) as any;
|
||||
const privScalar = bufferToBigint(Buffer.from(jwk.d!, "base64url"));
|
||||
const pubKeyString = exportPublicKeyString(ecPubKey);
|
||||
const uid = sha1(Buffer.from(pubKeyString, "ascii")).toString("base64");
|
||||
|
||||
const identity: IdentityData = {
|
||||
privateKey: ecPrivKey,
|
||||
publicKey: ecPubKey,
|
||||
privateKeyBigInt: privScalar,
|
||||
publicKeyString: pubKeyString,
|
||||
keyOffset: 0n,
|
||||
uid,
|
||||
};
|
||||
|
||||
if (securityLevel > 0) {
|
||||
improveSecurity(identity, securityLevel);
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
function improveSecurity(identity: IdentityData, toLevel: number): void {
|
||||
const pubKeyBytes = Buffer.from(identity.publicKeyString, "ascii");
|
||||
let offset = identity.keyOffset;
|
||||
let best = getSecurityLevel(pubKeyBytes, offset);
|
||||
|
||||
for (let checked_ = offset; ; checked_++) {
|
||||
if (best >= toLevel) {
|
||||
identity.keyOffset = offset;
|
||||
return;
|
||||
}
|
||||
const curr = getSecurityLevel(pubKeyBytes, checked_);
|
||||
if (curr > best) {
|
||||
offset = checked_;
|
||||
best = curr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSecurityLevel(pubKeyBytes: Buffer, offset: bigint): number {
|
||||
const offsetStr = offset.toString();
|
||||
const hashInput = Buffer.concat([
|
||||
pubKeyBytes,
|
||||
Buffer.from(offsetStr, "ascii"),
|
||||
]);
|
||||
const hash = sha1(hashInput);
|
||||
return countLeadingZeroBits(hash);
|
||||
}
|
||||
|
||||
function countLeadingZeroBits(data: Buffer): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i] === 0) {
|
||||
count += 8;
|
||||
} else {
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
if ((data[i] & (1 << bit)) === 0) count++;
|
||||
else return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Export public key in libtomcrypt ASN.1 format (for omega parameter)
|
||||
export function exportPublicKeyString(pubKey: crypto.KeyObject): string {
|
||||
const spki = pubKey.export({ type: "spki", format: "der" });
|
||||
const buf = Buffer.from(spki);
|
||||
let pointIdx = -1;
|
||||
for (let i = buf.length - 65; i >= 0; i--) {
|
||||
if (buf[i] === 0x04 && buf.length - i >= 65) {
|
||||
pointIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pointIdx < 0) throw new Error("Cannot extract EC point from SPKI");
|
||||
|
||||
const x = bufferToBigint(buf.subarray(pointIdx + 1, pointIdx + 33));
|
||||
const y = bufferToBigint(buf.subarray(pointIdx + 33, pointIdx + 65));
|
||||
|
||||
const der = buildLtcPublicKeyDer(x, y);
|
||||
return der.toString("base64");
|
||||
}
|
||||
|
||||
// ECDH shared secret (P-256) - returns x coordinate, SHA-1 hashed
|
||||
export function getSharedSecret(
|
||||
privateKey: crypto.KeyObject,
|
||||
serverPublicKeyDer: Buffer
|
||||
): Buffer {
|
||||
const parsed = parseDerSequence(serverPublicKeyDer);
|
||||
const x = parsed[2] as bigint;
|
||||
const y = parsed[3] as bigint;
|
||||
|
||||
const pubPoint = Buffer.concat([
|
||||
Buffer.from([0x04]),
|
||||
bigintToBuffer32(x),
|
||||
bigintToBuffer32(y),
|
||||
]);
|
||||
|
||||
const jwk = privateKey.export({ format: "jwk" }) as any;
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
ecdh.setPrivateKey(Buffer.from(jwk.d!, "base64url"));
|
||||
|
||||
const shared = ecdh.computeSecret(pubPoint);
|
||||
let keyArr: Buffer;
|
||||
if (shared.length === 32) {
|
||||
keyArr = shared;
|
||||
} else if (shared.length > 32) {
|
||||
keyArr = shared.subarray(shared.length - 32);
|
||||
} else {
|
||||
keyArr = Buffer.alloc(32, 0);
|
||||
shared.copy(keyArr, 32 - shared.length);
|
||||
}
|
||||
return crypto.createHash("sha1").update(keyArr).digest();
|
||||
}
|
||||
|
||||
// Simple DER parser for libtomcrypt key format
|
||||
function parseDerSequence(data: Buffer): (number | bigint)[] {
|
||||
const results: (number | bigint)[] = [];
|
||||
let pos = 0;
|
||||
|
||||
if (data[pos] !== 0x30) throw new Error("Expected SEQUENCE tag");
|
||||
pos++;
|
||||
const [seqLen, seqLenBytes] = readDerLength(data, pos);
|
||||
pos += seqLenBytes;
|
||||
const seqEnd = pos + seqLen;
|
||||
|
||||
while (pos < seqEnd) {
|
||||
const tag = data[pos];
|
||||
pos++;
|
||||
const [len, lenBytes] = readDerLength(data, pos);
|
||||
pos += lenBytes;
|
||||
|
||||
if (tag === 0x03) {
|
||||
const unusedBits = data[pos];
|
||||
const value = data[pos + 1];
|
||||
results.push(value);
|
||||
} else if (tag === 0x02) {
|
||||
const intBuf = data.subarray(pos, pos + len);
|
||||
results.push(bufferToBigint(intBuf));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function readDerLength(
|
||||
data: Buffer,
|
||||
pos: number
|
||||
): [number, number] {
|
||||
if (data[pos] < 0x80) return [data[pos], 1];
|
||||
const numBytes = data[pos] & 0x7f;
|
||||
let len = 0;
|
||||
for (let i = 0; i < numBytes; i++) {
|
||||
len = (len << 8) | data[pos + 1 + i];
|
||||
}
|
||||
return [len, 1 + numBytes];
|
||||
}
|
||||
|
||||
// Build libtomcrypt public key DER
|
||||
function buildLtcPublicKeyDer(x: bigint, y: bigint): Buffer {
|
||||
const bitStr = Buffer.from([0x03, 0x02, 0x07, 0x00]);
|
||||
const int32 = buildDerBigInteger(32n);
|
||||
const intX = buildDerBigInteger(x);
|
||||
const intY = buildDerBigInteger(y);
|
||||
const content = Buffer.concat([bitStr, int32, intX, intY]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x30]),
|
||||
buildDerLength(content.length),
|
||||
content,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildDerBigInteger(value: bigint): Buffer {
|
||||
let hex = value.toString(16);
|
||||
if (hex.length % 2) hex = "0" + hex;
|
||||
let buf = Buffer.from(hex, "hex");
|
||||
if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x02]),
|
||||
buildDerLength(buf.length),
|
||||
buf,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildDerLength(len: number): Buffer {
|
||||
if (len < 0x80) return Buffer.from([len]);
|
||||
if (len < 0x100) return Buffer.from([0x81, len]);
|
||||
return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);
|
||||
}
|
||||
|
||||
function bigintToBuffer32(n: bigint): Buffer {
|
||||
let hex = n.toString(16);
|
||||
if (hex.startsWith("-")) hex = hex.slice(1);
|
||||
while (hex.length < 64) hex = "0" + hex;
|
||||
if (hex.length > 64) hex = hex.slice(hex.length - 64);
|
||||
return Buffer.from(hex, "hex");
|
||||
}
|
||||
|
||||
function bufferToBigint(buf: Buffer): bigint {
|
||||
if (buf.length === 0) return 0n;
|
||||
return BigInt("0x" + buf.toString("hex"));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// TS3 Protocol Library (ported from DreamSpeak/TSLib)
|
||||
|
||||
export { Ts3Client } from './client.js';
|
||||
export type { Ts3ClientOptions } from './client.js';
|
||||
export { buildCommand, parseCommand, tsEscape, tsUnescape } from './commands.js';
|
||||
export type { ParsedCommand } from './commands.js';
|
||||
export { eaxEncrypt, eaxDecrypt, deriveKeyNonce, hashPassword, sha1, sha256, sha512 } from './crypto.js';
|
||||
export { generateIdentity, restoreIdentity, fromTsIdentity, fromBase64Key, exportPublicKeyString, getSharedSecret } from './identity.js';
|
||||
export type { IdentityData } from './identity.js';
|
||||
export { parseLicense, deriveLicenseKey, generateTemporaryKey, getSharedSecret2 } from './license.js';
|
||||
export { qlzDecompress } from './quicklz.js';
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as crypto from "crypto";
|
||||
import { sha512 } from "./crypto.js";
|
||||
|
||||
// We use libsodium-wrappers-sumo for Ed25519 point operations
|
||||
let sodium: any = null;
|
||||
|
||||
async function getSodium() {
|
||||
if (!sodium) {
|
||||
const mod = await import("libsodium-wrappers-sumo");
|
||||
await mod.default.ready;
|
||||
sodium = mod.default;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
// License root key (Ed25519 point, compressed)
|
||||
const LICENSE_ROOT_KEY = Buffer.from([
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd,
|
||||
0x8f, 0x68, 0xb3, 0xdc, 0x75, 0x55, 0xb2, 0x9d, 0xcc, 0xec, 0x73, 0xcd,
|
||||
0x18, 0x75, 0x0f, 0x99, 0x38, 0x12, 0x40, 0x8a,
|
||||
]);
|
||||
|
||||
interface LicenseBlock {
|
||||
key: Buffer; // 32 bytes
|
||||
hash: Buffer; // 32 bytes (first 32 of SHA-512 of block content)
|
||||
type: number;
|
||||
}
|
||||
|
||||
export function parseLicense(data: Buffer): LicenseBlock[] {
|
||||
if (data.length < 1) throw new Error("License too short");
|
||||
if (data[0] !== 1) throw new Error("Unsupported license version");
|
||||
|
||||
const blocks: LicenseBlock[] = [];
|
||||
let pos = 1;
|
||||
|
||||
while (pos < data.length) {
|
||||
if (data.length - pos < 42) throw new Error("License block too short");
|
||||
if (data[pos] !== 0) throw new Error(`Wrong key kind ${data[pos]}`);
|
||||
|
||||
const key = data.subarray(pos + 1, pos + 33);
|
||||
const blockType = data[pos + 33];
|
||||
|
||||
let extraLen = 0;
|
||||
switch (blockType) {
|
||||
case 0: // Intermediate
|
||||
extraLen = findNullTerminator(data, pos + 46) + 5;
|
||||
break;
|
||||
case 2: // Server
|
||||
extraLen = findNullTerminator(data, pos + 47) + 6;
|
||||
break;
|
||||
case 8: { // TS5 Server
|
||||
let p = pos + 44;
|
||||
const propCount = data[pos + 43];
|
||||
for (let i = 0; i < propCount; i++) {
|
||||
const propLen = data[p];
|
||||
p += 1 + propLen;
|
||||
}
|
||||
extraLen = p - (pos + 42);
|
||||
break;
|
||||
}
|
||||
case 32: // Ephemeral
|
||||
extraLen = 0;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid license block type ${blockType}`);
|
||||
}
|
||||
|
||||
const blockLen = 42 + extraLen;
|
||||
const hashInput = data.subarray(pos + 1, pos + blockLen);
|
||||
const hash = sha512(Buffer.from(hashInput)).subarray(0, 32);
|
||||
|
||||
blocks.push({
|
||||
key: Buffer.from(key),
|
||||
hash: Buffer.from(hash),
|
||||
type: blockType,
|
||||
});
|
||||
pos += blockLen;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function findNullTerminator(data: Buffer, start: number): number {
|
||||
for (let i = start; i < data.length; i++) {
|
||||
if (data[i] === 0) return i - start;
|
||||
}
|
||||
throw new Error("Non-null-terminated string in license");
|
||||
}
|
||||
|
||||
// Derive the final public key from the license chain
|
||||
export async function deriveLicenseKey(blocks: LicenseBlock[]): Promise<Buffer> {
|
||||
await getSodium();
|
||||
let parentKey: Buffer = Buffer.from(LICENSE_ROOT_KEY);
|
||||
|
||||
for (const block of blocks) {
|
||||
parentKey = deriveBlockKey(block.key, block.hash, parentKey);
|
||||
}
|
||||
|
||||
return parentKey;
|
||||
}
|
||||
|
||||
function deriveBlockKey(
|
||||
blockKey: Buffer,
|
||||
hash: Buffer,
|
||||
parentKey: Buffer
|
||||
): Buffer {
|
||||
const scalar = Buffer.from(hash);
|
||||
scalar[0] &= 0xf8;
|
||||
scalar[31] &= 0x3f;
|
||||
scalar[31] |= 0x40;
|
||||
|
||||
const mulResult = Buffer.from(
|
||||
sodium.crypto_scalarmult_ed25519_noclamp(
|
||||
new Uint8Array(scalar),
|
||||
new Uint8Array(blockKey)
|
||||
)
|
||||
);
|
||||
|
||||
const result = Buffer.from(
|
||||
sodium.crypto_core_ed25519_add(
|
||||
new Uint8Array(mulResult),
|
||||
new Uint8Array(parentKey)
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Compute shared secret for new protocol (initivexpand2)
|
||||
export async function getSharedSecret2(
|
||||
serverDerivedKey: Buffer,
|
||||
tempPrivateKey: Buffer
|
||||
): Promise<Buffer> {
|
||||
await getSodium();
|
||||
|
||||
const privKeyCopy = Buffer.from(tempPrivateKey);
|
||||
privKeyCopy[31] &= 0x7f;
|
||||
|
||||
const mulResult = Buffer.from(
|
||||
sodium.crypto_scalarmult_ed25519_noclamp(
|
||||
new Uint8Array(privKeyCopy),
|
||||
new Uint8Array(serverDerivedKey)
|
||||
)
|
||||
);
|
||||
|
||||
return sha512(mulResult);
|
||||
}
|
||||
|
||||
// Generate a temporary Ed25519 keypair
|
||||
export async function generateTemporaryKey(): Promise<{
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
}> {
|
||||
await getSodium();
|
||||
|
||||
const privateKey = Buffer.from(crypto.randomBytes(32));
|
||||
privateKey[0] &= 248;
|
||||
privateKey[31] &= 127;
|
||||
privateKey[31] |= 64;
|
||||
|
||||
const publicKey = Buffer.from(
|
||||
sodium.crypto_scalarmult_ed25519_base_noclamp(
|
||||
new Uint8Array(privateKey)
|
||||
)
|
||||
);
|
||||
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// QuickLZ level 1 decompression (ported from TSLib QuickerLz.cs)
|
||||
|
||||
const TABLE_SIZE = 4096;
|
||||
const MAX_DECOMPRESSED_SIZE = 1024 * 1024;
|
||||
|
||||
export function qlzGetDecompressedSize(data: Buffer): number {
|
||||
return (data[0] & 0x02) !== 0
|
||||
? data.readInt32LE(5)
|
||||
: data[2];
|
||||
}
|
||||
|
||||
export function qlzGetCompressedSize(data: Buffer): number {
|
||||
return (data[0] & 0x02) !== 0
|
||||
? data.readInt32LE(1)
|
||||
: data[1];
|
||||
}
|
||||
|
||||
function read24(buf: Buffer, off: number): number {
|
||||
return buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16);
|
||||
}
|
||||
|
||||
function hash(value: number): number {
|
||||
return ((value >> 12) ^ value) & 0xfff;
|
||||
}
|
||||
|
||||
export function qlzDecompress(data: Buffer): Buffer {
|
||||
const flags = data[0];
|
||||
const level = (flags >> 2) & 0x03;
|
||||
if (level !== 1) {
|
||||
throw new Error(`QuickLZ level ${level} not supported`);
|
||||
}
|
||||
|
||||
const headerLen = (flags & 0x02) !== 0 ? 9 : 3;
|
||||
const decompressedSize = qlzGetDecompressedSize(data);
|
||||
|
||||
if (decompressedSize > MAX_DECOMPRESSED_SIZE) {
|
||||
throw new Error(`Decompressed size ${decompressedSize} exceeds max`);
|
||||
}
|
||||
|
||||
const dest = Buffer.alloc(decompressedSize);
|
||||
|
||||
if ((flags & 0x01) === 0) {
|
||||
data.copy(dest, 0, headerLen, headerLen + decompressedSize);
|
||||
return dest;
|
||||
}
|
||||
|
||||
// Level 1 decompression
|
||||
const hashtable = new Int32Array(TABLE_SIZE);
|
||||
let control = 1;
|
||||
let sourcePos = headerLen;
|
||||
let destPos = 0;
|
||||
let nextHashed = 0;
|
||||
|
||||
while (true) {
|
||||
if (control === 1) {
|
||||
control = data.readUInt32LE(sourcePos) >>> 0;
|
||||
sourcePos += 4;
|
||||
}
|
||||
|
||||
if ((control & 1) !== 0) {
|
||||
// Back-reference
|
||||
control >>>= 1;
|
||||
const next = data[sourcePos++];
|
||||
const hashIdx = (next >> 4) | (data[sourcePos++] << 4);
|
||||
|
||||
let matchLen = next & 0x0f;
|
||||
if (matchLen !== 0) {
|
||||
matchLen += 2;
|
||||
} else {
|
||||
matchLen = data[sourcePos++];
|
||||
}
|
||||
|
||||
const offset = hashtable[hashIdx];
|
||||
|
||||
// Copy bytes (may overlap, byte-by-byte)
|
||||
dest[destPos] = dest[offset];
|
||||
dest[destPos + 1] = dest[offset + 1];
|
||||
dest[destPos + 2] = dest[offset + 2];
|
||||
for (let i = 3; i < matchLen; i++) {
|
||||
dest[destPos + i] = dest[offset + i];
|
||||
}
|
||||
destPos += matchLen;
|
||||
|
||||
// Update hash table
|
||||
const end = destPos + 1 - matchLen;
|
||||
if (nextHashed < end) {
|
||||
let nxt = read24(dest, nextHashed);
|
||||
hashtable[hash(nxt)] = nextHashed;
|
||||
for (let i = nextHashed + 1; i < end; i++) {
|
||||
nxt = (nxt >> 8) | (dest[i + 2] << 16);
|
||||
hashtable[hash(nxt)] = i;
|
||||
}
|
||||
}
|
||||
nextHashed = destPos;
|
||||
} else if (destPos >= Math.max(decompressedSize, 10) - 10) {
|
||||
// Near end, copy remaining literals
|
||||
while (destPos < decompressedSize) {
|
||||
if (control === 1) {
|
||||
sourcePos += 4;
|
||||
}
|
||||
control >>>= 1;
|
||||
dest[destPos++] = data[sourcePos++];
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
// Literal byte
|
||||
dest[destPos++] = data[sourcePos++];
|
||||
control >>>= 1;
|
||||
|
||||
const end = Math.max(destPos - 2, 0);
|
||||
if (nextHashed < end) {
|
||||
let nxt = read24(dest, nextHashed);
|
||||
hashtable[hash(nxt)] = nextHashed;
|
||||
for (let i = nextHashed + 1; i < end; i++) {
|
||||
nxt = (nxt >> 8) | (dest[i + 2] << 16);
|
||||
hashtable[hash(nxt)] = i;
|
||||
}
|
||||
}
|
||||
nextHashed = Math.max(nextHashed, end);
|
||||
}
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { PrismaClient } from '../../generated/prisma/index.js';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
import { VoiceBot, type VoiceBotConfig, type VoiceBotStatus } from './voice-bot.js';
|
||||
import { generateIdentity, restoreIdentity, type IdentityData } from './tslib/index.js';
|
||||
import type { QueueItem } from './playlist/queue.js';
|
||||
import type { MusicCommandHandler } from './music-command-handler.js';
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 1000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
const MAX_RECONNECT_DELAY_MS = 30000;
|
||||
|
||||
interface ReconnectState {
|
||||
attempts: number;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export class VoiceBotManager extends EventEmitter {
|
||||
private bots = new Map<number, VoiceBot>();
|
||||
private progressTimers = new Map<number, ReturnType<typeof setInterval>>();
|
||||
private reconnectState = new Map<number, ReconnectState>();
|
||||
private musicCmdHandler: MusicCommandHandler | null = null;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private wss: WebSocketServer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
setMusicCommandHandler(handler: MusicCommandHandler): void {
|
||||
this.musicCmdHandler = handler;
|
||||
// Register all existing bots
|
||||
for (const [id, bot] of this.bots) {
|
||||
handler.registerBot(id, bot);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const dbBots = await this.prisma.musicBot.findMany({
|
||||
include: { serverConfig: true },
|
||||
});
|
||||
|
||||
console.log(`[VoiceBotManager] Loading ${dbBots.length} music bot(s)...`);
|
||||
|
||||
for (const dbBot of dbBots) {
|
||||
let identity: IdentityData | undefined;
|
||||
if (dbBot.identityData) {
|
||||
const parsed = JSON.parse(dbBot.identityData);
|
||||
// Reconstruct KeyObjects from serialized scalar data
|
||||
identity = restoreIdentity(parsed);
|
||||
}
|
||||
const config: VoiceBotConfig = {
|
||||
id: dbBot.id,
|
||||
name: dbBot.name,
|
||||
serverHost: dbBot.serverConfig.host,
|
||||
serverPort: 9987, // Default TS3 voice port
|
||||
nickname: dbBot.nickname,
|
||||
defaultChannel: dbBot.defaultChannel ?? undefined,
|
||||
channelPassword: dbBot.channelPassword ?? undefined,
|
||||
volume: dbBot.volume,
|
||||
identity,
|
||||
};
|
||||
|
||||
const bot = this.createBotInstance(config);
|
||||
this.bots.set(dbBot.id, bot);
|
||||
|
||||
if (dbBot.autoStart) {
|
||||
bot.start().catch((err) => {
|
||||
console.error(`[VoiceBotManager] Auto-start failed for bot ${dbBot.id}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createBotInstance(config: VoiceBotConfig): VoiceBot {
|
||||
const bot = new VoiceBot(config);
|
||||
|
||||
bot.on('statusChange', (status: VoiceBotStatus) => {
|
||||
this.broadcast('music:bot:status', { botId: config.id, status });
|
||||
|
||||
if (status === 'playing') {
|
||||
this.startProgressBroadcast(config.id);
|
||||
} else {
|
||||
this.stopProgressBroadcast(config.id);
|
||||
}
|
||||
});
|
||||
|
||||
bot.on('error', (err: Error) => {
|
||||
console.error(`[VoiceBotManager] Bot ${config.id} error: ${err.message}`);
|
||||
});
|
||||
|
||||
bot.on('nowPlaying', (item: QueueItem) => {
|
||||
const progress = bot.playbackProgress;
|
||||
this.broadcast('music:bot:nowPlaying', {
|
||||
botId: config.id,
|
||||
song: { id: item.id, title: item.title, artist: item.artist, duration: item.duration, source: item.source },
|
||||
progress: progress ? { position: progress.position, duration: progress.duration } : null,
|
||||
});
|
||||
});
|
||||
|
||||
bot.on('trackEnd', (item: QueueItem | null) => {
|
||||
this.broadcast('music:bot:trackEnd', { botId: config.id, songId: item?.id ?? null });
|
||||
});
|
||||
|
||||
bot.on('volumeChange', (volume: number) => {
|
||||
this.broadcast('music:bot:volumeChange', { botId: config.id, volume });
|
||||
});
|
||||
|
||||
bot.on('metadataChange', (item: QueueItem) => {
|
||||
this.broadcast('music:bot:nowPlaying', {
|
||||
botId: config.id,
|
||||
song: { id: item.id, title: item.title, artist: item.artist, duration: item.duration, source: item.source },
|
||||
progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
bot.on('disconnected', () => {
|
||||
if (!bot.manuallyStopped) {
|
||||
console.log(`[VoiceBotManager] Bot ${config.id}: unexpected disconnect, scheduling reconnect`);
|
||||
this.scheduleReconnect(config.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Register for music text commands
|
||||
if (this.musicCmdHandler) {
|
||||
this.musicCmdHandler.registerBot(config.id, bot);
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
async createBot(data: {
|
||||
name: string;
|
||||
serverConfigId: number;
|
||||
nickname?: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
volume?: number;
|
||||
autoStart?: boolean;
|
||||
}): Promise<{ id: number }> {
|
||||
// Enforce bot limit
|
||||
const limitSetting = await this.prisma.appSetting.findUnique({ where: { key: 'max_music_bots' } });
|
||||
const limit = parseInt(limitSetting?.value ?? '10') || 10;
|
||||
const currentCount = await this.prisma.musicBot.count();
|
||||
if (currentCount >= limit) {
|
||||
throw new Error(`Music bot limit reached (${limit}). Adjust the limit in Settings.`);
|
||||
}
|
||||
|
||||
// Generate identity (BigInt fields must be serialized as strings)
|
||||
const identity = generateIdentity(8);
|
||||
const identityData = JSON.stringify(identity, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
);
|
||||
|
||||
// Get server config for host
|
||||
const serverConfig = await this.prisma.tsServerConfig.findUnique({ where: { id: data.serverConfigId } });
|
||||
if (!serverConfig) throw new Error('Server config not found');
|
||||
|
||||
const dbBot = await this.prisma.musicBot.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
serverConfigId: data.serverConfigId,
|
||||
nickname: data.nickname ?? 'MusicBot',
|
||||
defaultChannel: data.defaultChannel,
|
||||
channelPassword: data.channelPassword,
|
||||
volume: data.volume ?? 50,
|
||||
autoStart: data.autoStart ?? false,
|
||||
identityData,
|
||||
},
|
||||
});
|
||||
|
||||
const config: VoiceBotConfig = {
|
||||
id: dbBot.id,
|
||||
name: dbBot.name,
|
||||
serverHost: serverConfig.host,
|
||||
serverPort: 9987,
|
||||
nickname: dbBot.nickname,
|
||||
defaultChannel: dbBot.defaultChannel ?? undefined,
|
||||
channelPassword: dbBot.channelPassword ?? undefined,
|
||||
volume: dbBot.volume,
|
||||
identity,
|
||||
};
|
||||
|
||||
const bot = this.createBotInstance(config);
|
||||
this.bots.set(dbBot.id, bot);
|
||||
|
||||
return { id: dbBot.id };
|
||||
}
|
||||
|
||||
getBot(id: number): VoiceBot | undefined {
|
||||
return this.bots.get(id);
|
||||
}
|
||||
|
||||
async removeBot(id: number): Promise<void> {
|
||||
this.clearReconnect(id);
|
||||
const bot = this.bots.get(id);
|
||||
if (bot && bot.status !== 'stopped') {
|
||||
await bot.stop();
|
||||
}
|
||||
this.stopProgressBroadcast(id);
|
||||
this.bots.delete(id);
|
||||
await this.prisma.musicBot.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getBotsForServer(configId: number): Promise<Array<{ botId: number; bot: VoiceBot }>> {
|
||||
const dbBots = await this.prisma.musicBot.findMany({
|
||||
where: { serverConfigId: configId },
|
||||
select: { id: true },
|
||||
});
|
||||
const result: Array<{ botId: number; bot: VoiceBot }> = [];
|
||||
for (const db of dbBots) {
|
||||
const bot = this.bots.get(db.id);
|
||||
if (bot && bot.status !== 'stopped') {
|
||||
result.push({ botId: db.id, bot });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
listBots(): Array<{ id: number; status: VoiceBotStatus; nowPlaying: QueueItem | null }> {
|
||||
const list: Array<{ id: number; status: VoiceBotStatus; nowPlaying: QueueItem | null }> = [];
|
||||
for (const [id, bot] of this.bots) {
|
||||
list.push({ id, status: bot.status, nowPlaying: bot.nowPlaying });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async startBot(id: number): Promise<void> {
|
||||
const bot = this.bots.get(id);
|
||||
if (!bot) throw new Error(`Music bot ${id} not found`);
|
||||
this.clearReconnect(id);
|
||||
await bot.start();
|
||||
}
|
||||
|
||||
async stopBot(id: number): Promise<void> {
|
||||
const bot = this.bots.get(id);
|
||||
if (!bot) throw new Error(`Music bot ${id} not found`);
|
||||
this.clearReconnect(id);
|
||||
await bot.stop();
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
// Clear all reconnect timers first to prevent reconnect during shutdown
|
||||
for (const [id, state] of this.reconnectState) {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
}
|
||||
this.reconnectState.clear();
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const bot of this.bots.values()) {
|
||||
if (bot.status !== 'stopped') {
|
||||
promises.push(bot.stop());
|
||||
}
|
||||
}
|
||||
this.progressTimers.forEach((timer) => clearInterval(timer));
|
||||
this.progressTimers.clear();
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// --- Auto-reconnect logic ---
|
||||
|
||||
private scheduleReconnect(botId: number): void {
|
||||
const bot = this.bots.get(botId);
|
||||
if (!bot) return;
|
||||
|
||||
let state = this.reconnectState.get(botId);
|
||||
if (!state) {
|
||||
state = { attempts: 0, timer: null };
|
||||
this.reconnectState.set(botId, state);
|
||||
}
|
||||
|
||||
// Prevent double-scheduling (can happen when both 'disconnected' handler
|
||||
// and attemptReconnect catch block trigger simultaneously)
|
||||
if (state.timer) return;
|
||||
|
||||
if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.error(`[VoiceBotManager] Bot ${botId}: max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`);
|
||||
this.broadcast('music:bot:reconnectFailed', { botId });
|
||||
this.reconnectState.delete(botId);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(Math.pow(2, state.attempts) * 1000, MAX_RECONNECT_DELAY_MS);
|
||||
state.attempts++;
|
||||
console.log(`[VoiceBotManager] Bot ${botId}: reconnect attempt ${state.attempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s`);
|
||||
|
||||
state.timer = setTimeout(() => this.attemptReconnect(botId), delay);
|
||||
}
|
||||
|
||||
private async attemptReconnect(botId: number): Promise<void> {
|
||||
const bot = this.bots.get(botId);
|
||||
const state = this.reconnectState.get(botId);
|
||||
if (!bot || !state) return;
|
||||
|
||||
// Mark timer as executed so scheduleReconnect can run again
|
||||
state.timer = null;
|
||||
|
||||
try {
|
||||
await bot.start();
|
||||
console.log(`[VoiceBotManager] Bot ${botId}: reconnected successfully after ${state.attempts} attempt(s)`);
|
||||
this.reconnectState.delete(botId);
|
||||
} catch (err: any) {
|
||||
console.error(`[VoiceBotManager] Bot ${botId}: reconnect attempt ${state.attempts} failed: ${err.message}`);
|
||||
// Schedule next attempt (guard in scheduleReconnect prevents double-scheduling
|
||||
// if 'disconnected' event also fires from the failed connect)
|
||||
this.scheduleReconnect(botId);
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnect(botId: number): void {
|
||||
const state = this.reconnectState.get(botId);
|
||||
if (state?.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
this.reconnectState.delete(botId);
|
||||
}
|
||||
|
||||
private startProgressBroadcast(botId: number): void {
|
||||
this.stopProgressBroadcast(botId);
|
||||
const timer = setInterval(() => {
|
||||
const bot = this.bots.get(botId);
|
||||
if (!bot || bot.status !== 'playing') {
|
||||
this.stopProgressBroadcast(botId);
|
||||
return;
|
||||
}
|
||||
const progress = bot.playbackProgress;
|
||||
if (progress) {
|
||||
this.broadcast('music:bot:progress', {
|
||||
botId,
|
||||
position: progress.position,
|
||||
duration: progress.duration,
|
||||
});
|
||||
}
|
||||
}, PROGRESS_INTERVAL_MS);
|
||||
this.progressTimers.set(botId, timer);
|
||||
}
|
||||
|
||||
private stopProgressBroadcast(botId: number): void {
|
||||
const timer = this.progressTimers.get(botId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.progressTimers.delete(botId);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(type: string, payload: any): void {
|
||||
const msg = JSON.stringify({ type, ...payload });
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
client.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { Ts3Client, type Ts3ClientOptions, generateIdentity, type IdentityData, buildCommand } from './tslib/index.js';
|
||||
import { AudioPipeline, FRAME_MS, BYTES_PER_FRAME } from './audio/pipeline.js';
|
||||
import { PlayQueue, type QueueItem } from './playlist/queue.js';
|
||||
import { fetchIcyMetadata } from './audio/icy-metadata.js';
|
||||
|
||||
export type VoiceBotStatus = 'stopped' | 'starting' | 'connected' | 'playing' | 'paused' | 'error';
|
||||
|
||||
export interface PlaybackProgress {
|
||||
position: number; // seconds
|
||||
duration: number; // seconds
|
||||
}
|
||||
|
||||
export interface VoiceBotConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
serverHost: string;
|
||||
serverPort: number;
|
||||
nickname: string;
|
||||
serverPassword?: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
volume: number; // 0-100
|
||||
identity?: IdentityData;
|
||||
}
|
||||
|
||||
export class VoiceBot extends EventEmitter {
|
||||
private client: Ts3Client;
|
||||
private pipeline: AudioPipeline;
|
||||
readonly queue: PlayQueue;
|
||||
private config: VoiceBotConfig;
|
||||
private _status: VoiceBotStatus = 'stopped';
|
||||
private identity: IdentityData | null = null;
|
||||
private playbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _nowPlaying: QueueItem | null = null;
|
||||
|
||||
// PCM-level playback state
|
||||
private pcmFrames: Buffer[] = [];
|
||||
private frameIndex: number = 0;
|
||||
private pausedAtFrame: number = 0;
|
||||
private loopEpoch: number = 0;
|
||||
|
||||
// Streaming state (radio)
|
||||
private _isStreaming: boolean = false;
|
||||
private streamKill: (() => void) | null = null;
|
||||
private streamBuffer: Buffer = Buffer.alloc(0);
|
||||
private streamStartTime: number = 0;
|
||||
|
||||
// Nickname "now playing" state
|
||||
private _originalNickname: string;
|
||||
|
||||
// ICY metadata polling (radio)
|
||||
private icyPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastStreamTitle: string = '';
|
||||
|
||||
// Reconnect: distinguishes manual stop from unexpected disconnect
|
||||
private _manuallyStopped: boolean = false;
|
||||
|
||||
constructor(config: VoiceBotConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
this._originalNickname = config.nickname;
|
||||
this.client = new Ts3Client();
|
||||
this.pipeline = new AudioPipeline();
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this._status = 'error';
|
||||
this.emit('error', err);
|
||||
this.emit('statusChange', this._status);
|
||||
});
|
||||
|
||||
this.client.on('disconnected', () => {
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this._status = 'stopped';
|
||||
this._nowPlaying = null;
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.client.on('command', (cmd) => {
|
||||
this.emit('command', cmd);
|
||||
});
|
||||
|
||||
this.client.on('textMessage', (data: Record<string, string>) => {
|
||||
this.emit('textMessage', data);
|
||||
});
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
get status(): VoiceBotStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get nowPlaying(): QueueItem | null {
|
||||
return this._nowPlaying;
|
||||
}
|
||||
|
||||
get isStreaming(): boolean {
|
||||
return this._isStreaming;
|
||||
}
|
||||
|
||||
get manuallyStopped(): boolean {
|
||||
return this._manuallyStopped;
|
||||
}
|
||||
|
||||
get playbackProgress(): PlaybackProgress | null {
|
||||
if (!this._nowPlaying) return null;
|
||||
if (this._isStreaming) {
|
||||
return {
|
||||
position: (Date.now() - this.streamStartTime) / 1000,
|
||||
duration: 0, // Live stream — no known duration
|
||||
};
|
||||
}
|
||||
if (this.pcmFrames.length === 0) return null;
|
||||
return {
|
||||
position: (this.frameIndex * FRAME_MS) / 1000,
|
||||
duration: (this.pcmFrames.length * FRAME_MS) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
get ts3ClientId(): number {
|
||||
return this.client.getClientId();
|
||||
}
|
||||
|
||||
sendTextMessage(targetClid: number, msg: string): void {
|
||||
const cmd = buildCommand('sendtextmessage', {
|
||||
targetmode: 1,
|
||||
target: targetClid,
|
||||
msg,
|
||||
});
|
||||
this.client.sendCommand(cmd);
|
||||
}
|
||||
|
||||
get currentConfig(): VoiceBotConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(partial: Partial<VoiceBotConfig>): void {
|
||||
Object.assign(this.config, partial);
|
||||
if (partial.nickname) this._originalNickname = partial.nickname;
|
||||
}
|
||||
|
||||
/** Update the TS3 nickname to show what's playing. Max 30 chars. */
|
||||
private updateNowPlayingNickname(title: string): void {
|
||||
if (this._status === 'stopped') return;
|
||||
const prefix = this._originalNickname;
|
||||
const sep = ' \u266A '; // ♪
|
||||
const maxLen = 30;
|
||||
let nick = prefix + sep + title;
|
||||
if (nick.length > maxLen) {
|
||||
const available = maxLen - prefix.length - sep.length - 1; // -1 for …
|
||||
nick = prefix + sep + (available > 0 ? title.substring(0, available) + '\u2026' : '\u2026');
|
||||
}
|
||||
try {
|
||||
this.client.sendCommand(buildCommand('clientupdate', { client_nickname: nick }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Reset TS3 nickname to original. */
|
||||
private resetNickname(): void {
|
||||
if (this._status === 'stopped') return;
|
||||
try {
|
||||
this.client.sendCommand(buildCommand('clientupdate', { client_nickname: this._originalNickname }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Start polling ICY metadata for a radio stream. */
|
||||
private startIcyPolling(streamUrl: string): void {
|
||||
this.stopIcyPolling();
|
||||
this.lastStreamTitle = '';
|
||||
|
||||
// Immediate first fetch
|
||||
this.fetchAndUpdateIcy(streamUrl);
|
||||
|
||||
this.icyPollTimer = setInterval(() => {
|
||||
this.fetchAndUpdateIcy(streamUrl);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
private async fetchAndUpdateIcy(streamUrl: string): Promise<void> {
|
||||
try {
|
||||
const title = await fetchIcyMetadata(streamUrl);
|
||||
if (!title || title === this.lastStreamTitle || !this._nowPlaying) return;
|
||||
this.lastStreamTitle = title;
|
||||
|
||||
// Parse "Artist - Title" format
|
||||
const dashIdx = title.indexOf(' - ');
|
||||
if (dashIdx > 0) {
|
||||
this._nowPlaying.artist = title.substring(0, dashIdx).trim();
|
||||
this._nowPlaying.title = title.substring(dashIdx + 3).trim();
|
||||
} else {
|
||||
this._nowPlaying.title = title;
|
||||
}
|
||||
|
||||
this.updateNowPlayingNickname(title);
|
||||
this.emit('metadataChange', this._nowPlaying);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private stopIcyPolling(): void {
|
||||
if (this.icyPollTimer) {
|
||||
clearInterval(this.icyPollTimer);
|
||||
this.icyPollTimer = null;
|
||||
}
|
||||
this.lastStreamTitle = '';
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._status === 'connected' || this._status === 'playing' || this._status === 'paused') {
|
||||
throw new Error('Bot is already running');
|
||||
}
|
||||
|
||||
this._manuallyStopped = false;
|
||||
this._status = 'starting';
|
||||
this.emit('statusChange', this._status);
|
||||
|
||||
this.identity = this.config.identity ?? generateIdentity(8);
|
||||
|
||||
const opts: Ts3ClientOptions = {
|
||||
host: this.config.serverHost,
|
||||
port: this.config.serverPort,
|
||||
identity: this.identity,
|
||||
nickname: this.config.nickname,
|
||||
serverPassword: this.config.serverPassword,
|
||||
defaultChannel: this.config.defaultChannel,
|
||||
channelPassword: this.config.channelPassword,
|
||||
};
|
||||
|
||||
await this.client.connect(opts);
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('connected');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this._manuallyStopped = true;
|
||||
this.stopIcyPolling();
|
||||
this.resetNickname();
|
||||
this.stopPlayback();
|
||||
this._nowPlaying = null;
|
||||
this.client.disconnect();
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await this.stop();
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (this._status === 'stopped') resolve();
|
||||
else setTimeout(check, 100);
|
||||
};
|
||||
setTimeout(check, 600);
|
||||
});
|
||||
await this.start();
|
||||
}
|
||||
|
||||
async play(item: QueueItem): Promise<void> {
|
||||
if (this._status !== 'connected' && this._status !== 'playing' && this._status !== 'paused') {
|
||||
throw new Error('Bot is not connected');
|
||||
}
|
||||
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this._nowPlaying = item;
|
||||
this._status = 'playing';
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('nowPlaying', item);
|
||||
this.updateNowPlayingNickname(item.title);
|
||||
|
||||
try {
|
||||
const pcmData = await this.pipeline.toPcm(item.filePath);
|
||||
this.pcmFrames = this.pipeline.splitFrames(pcmData);
|
||||
this.frameIndex = 0;
|
||||
this.startPlaybackLoop();
|
||||
} catch (err) {
|
||||
this._status = 'connected';
|
||||
this._nowPlaying = null;
|
||||
this.emit('statusChange', this._status);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async playStream(item: QueueItem): Promise<void> {
|
||||
if (this._status !== 'connected' && this._status !== 'playing' && this._status !== 'paused') {
|
||||
throw new Error('Bot is not connected');
|
||||
}
|
||||
if (!item.streamUrl) {
|
||||
throw new Error('No streamUrl provided');
|
||||
}
|
||||
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this._nowPlaying = item;
|
||||
this._isStreaming = true;
|
||||
this._status = 'playing';
|
||||
this.streamStartTime = Date.now();
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('nowPlaying', item);
|
||||
this.updateNowPlayingNickname(item.title);
|
||||
this.startIcyPolling(item.streamUrl);
|
||||
|
||||
try {
|
||||
const stream = this.pipeline.toPcmStream(item.streamUrl);
|
||||
this.streamKill = stream.kill;
|
||||
this.streamBuffer = Buffer.alloc(0);
|
||||
|
||||
const epoch = ++this.loopEpoch;
|
||||
let framesSent = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
stream.stdout.on('data', (chunk: Buffer) => {
|
||||
if (epoch !== this.loopEpoch) return;
|
||||
this.streamBuffer = Buffer.concat([this.streamBuffer, chunk]);
|
||||
});
|
||||
|
||||
stream.process.on('close', () => {
|
||||
if (epoch !== this.loopEpoch) return;
|
||||
this.client.sendVoiceStop();
|
||||
this._isStreaming = false;
|
||||
this.streamKill = null;
|
||||
this._nowPlaying = null;
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('trackEnd', item);
|
||||
});
|
||||
|
||||
stream.process.on('error', (err) => {
|
||||
if (epoch !== this.loopEpoch) return;
|
||||
this._isStreaming = false;
|
||||
this.streamKill = null;
|
||||
this._status = 'error';
|
||||
this.emit('error', err);
|
||||
this.emit('statusChange', this._status);
|
||||
});
|
||||
|
||||
// Playback loop: send frames at 20ms intervals from the buffer
|
||||
const sendLoop = () => {
|
||||
if (epoch !== this.loopEpoch) return;
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
const targetFrames = Math.floor(elapsed / FRAME_MS);
|
||||
|
||||
while (framesSent < targetFrames && this.streamBuffer.length >= BYTES_PER_FRAME) {
|
||||
const frame = this.streamBuffer.subarray(0, BYTES_PER_FRAME);
|
||||
this.streamBuffer = this.streamBuffer.subarray(BYTES_PER_FRAME);
|
||||
const opusFrame = this.pipeline.encodeFrame(frame, this.config.volume);
|
||||
this.client.sendVoice(opusFrame);
|
||||
framesSent++;
|
||||
}
|
||||
|
||||
this.playbackTimer = setTimeout(sendLoop, 5);
|
||||
};
|
||||
|
||||
this.playbackTimer = setTimeout(sendLoop, 200); // Initial buffer delay
|
||||
} catch (err) {
|
||||
this._isStreaming = false;
|
||||
this.streamKill = null;
|
||||
this._status = 'connected';
|
||||
this._nowPlaying = null;
|
||||
this.emit('statusChange', this._status);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this._status !== 'playing') return;
|
||||
this.pausedAtFrame = this.frameIndex;
|
||||
this.clearTimer();
|
||||
this.client.sendVoiceStop();
|
||||
this._status = 'paused';
|
||||
this.emit('statusChange', this._status);
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._status !== 'paused') return;
|
||||
this.frameIndex = this.pausedAtFrame;
|
||||
this._status = 'playing';
|
||||
this.emit('statusChange', this._status);
|
||||
this.startPlaybackLoop();
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
if (this._status !== 'playing' && this._status !== 'paused') return;
|
||||
if (this.pcmFrames.length === 0) return;
|
||||
|
||||
const targetFrame = Math.max(0, Math.min(
|
||||
Math.floor(seconds / (FRAME_MS / 1000)),
|
||||
this.pcmFrames.length - 1
|
||||
));
|
||||
|
||||
if (this._status === 'playing') {
|
||||
this.clearTimer();
|
||||
this.frameIndex = targetFrame;
|
||||
this.startPlaybackLoop();
|
||||
} else {
|
||||
this.frameIndex = targetFrame;
|
||||
this.pausedAtFrame = targetFrame;
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.config.volume = Math.max(0, Math.min(100, volume));
|
||||
this.emit('volumeChange', this.config.volume);
|
||||
}
|
||||
|
||||
skip(): void {
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this._nowPlaying = null;
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
|
||||
const next = this.queue.next();
|
||||
if (next) {
|
||||
this.play(next).catch((err) => this.emit('error', err));
|
||||
} else {
|
||||
this.resetNickname();
|
||||
}
|
||||
}
|
||||
|
||||
previous(): void {
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this._nowPlaying = null;
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
|
||||
const prev = this.queue.previous();
|
||||
if (prev) {
|
||||
this.play(prev).catch((err) => this.emit('error', err));
|
||||
} else {
|
||||
this.resetNickname();
|
||||
}
|
||||
}
|
||||
|
||||
stopAudio(): void {
|
||||
this.stopIcyPolling();
|
||||
this.stopPlayback();
|
||||
this.client.sendVoiceStop();
|
||||
this._nowPlaying = null;
|
||||
this.resetNickname();
|
||||
if (this._status === 'playing' || this._status === 'paused') {
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
}
|
||||
}
|
||||
|
||||
private startPlaybackLoop(): void {
|
||||
const epoch = ++this.loopEpoch;
|
||||
const startTime = performance.now();
|
||||
const startFrame = this.frameIndex;
|
||||
|
||||
const sendNext = () => {
|
||||
if (epoch !== this.loopEpoch) return;
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
const targetFrame = startFrame + Math.floor(elapsed / FRAME_MS);
|
||||
|
||||
while (this.frameIndex < this.pcmFrames.length && this.frameIndex <= targetFrame) {
|
||||
const opusFrame = this.pipeline.encodeFrame(this.pcmFrames[this.frameIndex], this.config.volume);
|
||||
this.client.sendVoice(opusFrame);
|
||||
this.frameIndex++;
|
||||
}
|
||||
|
||||
if (this.frameIndex >= this.pcmFrames.length) {
|
||||
this.client.sendVoiceStop();
|
||||
this.clearTimer();
|
||||
const finished = this._nowPlaying;
|
||||
this._nowPlaying = null;
|
||||
this._status = 'connected';
|
||||
this.emit('statusChange', this._status);
|
||||
this.emit('trackEnd', finished);
|
||||
|
||||
// Track repeat: replay the same song (works even without queue)
|
||||
if (this.queue.repeat === 'track' && finished) {
|
||||
this.play(finished).catch((err) => this.emit('error', err));
|
||||
return;
|
||||
}
|
||||
|
||||
const next = this.queue.next();
|
||||
if (next) {
|
||||
this.play(next).catch((err) => this.emit('error', err));
|
||||
} else {
|
||||
this.resetNickname();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrameTime = startTime + (this.frameIndex - startFrame) * FRAME_MS;
|
||||
const delay = Math.max(1, nextFrameTime - performance.now());
|
||||
this.playbackTimer = setTimeout(sendNext, delay);
|
||||
};
|
||||
|
||||
this.playbackTimer = setTimeout(sendNext, 0);
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
this.loopEpoch++;
|
||||
if (this.playbackTimer) {
|
||||
clearTimeout(this.playbackTimer);
|
||||
this.playbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlayback(): void {
|
||||
this.clearTimer();
|
||||
this.pcmFrames = [];
|
||||
this.frameIndex = 0;
|
||||
this.pausedAtFrame = 0;
|
||||
|
||||
// Kill streaming FFmpeg if active
|
||||
if (this.streamKill) {
|
||||
this.streamKill();
|
||||
this.streamKill = null;
|
||||
}
|
||||
this._isStreaming = false;
|
||||
this.streamBuffer = Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@ts6/common",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// TeamSpeak ServerQuery Event Names
|
||||
export const TS_EVENTS = {
|
||||
CLIENT_ENTER: 'notifycliententerview',
|
||||
CLIENT_LEFT: 'notifyclientleftview',
|
||||
CLIENT_MOVED: 'notifyclientmoved',
|
||||
SERVER_EDITED: 'notifyserveredited',
|
||||
CHANNEL_EDITED: 'notifychanneledited',
|
||||
CHANNEL_DESCRIPTION_CHANGED: 'notifychanneldescriptionchanged',
|
||||
CHANNEL_CREATED: 'notifychannelcreated',
|
||||
CHANNEL_DELETED: 'notifychanneldeleted',
|
||||
CHANNEL_MOVED: 'notifychannelmoved',
|
||||
CHANNEL_PASSWORD_CHANGED: 'notifychannelpasswordchanged',
|
||||
TEXT_MESSAGE: 'notifytextmessage',
|
||||
TOKEN_USED: 'notifytokenused',
|
||||
} as const;
|
||||
|
||||
export type TSEventName = (typeof TS_EVENTS)[keyof typeof TS_EVENTS];
|
||||
|
||||
export const TS_EVENT_LABELS: Record<string, string> = {
|
||||
[TS_EVENTS.CLIENT_ENTER]: 'Client Connected',
|
||||
[TS_EVENTS.CLIENT_LEFT]: 'Client Disconnected',
|
||||
[TS_EVENTS.CLIENT_MOVED]: 'Client Moved',
|
||||
[TS_EVENTS.SERVER_EDITED]: 'Server Edited',
|
||||
[TS_EVENTS.CHANNEL_EDITED]: 'Channel Edited',
|
||||
[TS_EVENTS.CHANNEL_DESCRIPTION_CHANGED]: 'Channel Description Changed',
|
||||
[TS_EVENTS.CHANNEL_CREATED]: 'Channel Created',
|
||||
[TS_EVENTS.CHANNEL_DELETED]: 'Channel Deleted',
|
||||
[TS_EVENTS.CHANNEL_MOVED]: 'Channel Moved',
|
||||
[TS_EVENTS.CHANNEL_PASSWORD_CHANGED]: 'Channel Password Changed',
|
||||
[TS_EVENTS.TEXT_MESSAGE]: 'Text Message Received',
|
||||
[TS_EVENTS.TOKEN_USED]: 'Privilege Key Used',
|
||||
};
|
||||
|
||||
// Event registration types for servernotifyregister
|
||||
export const TS_EVENT_TYPES = ['server', 'channel', 'textserver', 'textchannel', 'textprivate'] as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './types/teamspeak.js';
|
||||
export * from './types/api.js';
|
||||
export * from './types/bot.js';
|
||||
export * from './types/auth.js';
|
||||
export * from './types/music.js';
|
||||
export * from './constants/events.js';
|
||||
export * from './utils/ts-escape.js';
|
||||
@@ -0,0 +1,202 @@
|
||||
// REST API Types between Frontend <-> Backend
|
||||
|
||||
// Auth
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export interface RefreshRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'moderator' | 'viewer';
|
||||
}
|
||||
|
||||
// Server Connection Config
|
||||
export interface ServerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
webqueryPort: number;
|
||||
useHttps: boolean;
|
||||
sshPort: number;
|
||||
hasSshCredentials: boolean;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateServerConfig {
|
||||
name: string;
|
||||
host: string;
|
||||
webqueryPort: number;
|
||||
apiKey: string;
|
||||
useHttps: boolean;
|
||||
sshPort: number;
|
||||
sshUsername?: string;
|
||||
sshPassword?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServerConfig {
|
||||
name?: string;
|
||||
host?: string;
|
||||
webqueryPort?: number;
|
||||
apiKey?: string;
|
||||
useHttps?: boolean;
|
||||
sshPort?: number;
|
||||
sshUsername?: string;
|
||||
sshPassword?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export interface DashboardData {
|
||||
serverName: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
onlineUsers: number;
|
||||
maxClients: number;
|
||||
uptime: number;
|
||||
channelCount: number;
|
||||
bandwidth: {
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
};
|
||||
packetloss: number;
|
||||
ping: number;
|
||||
}
|
||||
|
||||
// Channel operations
|
||||
export interface CreateChannelRequest {
|
||||
channel_name: string;
|
||||
channel_topic?: string;
|
||||
channel_description?: string;
|
||||
channel_password?: string;
|
||||
cpid?: number;
|
||||
channel_order?: number;
|
||||
channel_codec?: number;
|
||||
channel_codec_quality?: number;
|
||||
channel_maxclients?: number;
|
||||
channel_maxfamilyclients?: number;
|
||||
channel_flag_permanent?: number;
|
||||
channel_flag_semi_permanent?: number;
|
||||
channel_flag_temporary?: number;
|
||||
channel_flag_default?: number;
|
||||
channel_needed_talk_power?: number;
|
||||
}
|
||||
|
||||
export interface MoveChannelRequest {
|
||||
cpid: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
// Client actions
|
||||
export interface KickClientRequest {
|
||||
reasonid: 4 | 5; // 4=channel, 5=server
|
||||
reasonmsg?: string;
|
||||
}
|
||||
|
||||
export interface BanClientRequest {
|
||||
time?: number; // seconds, 0=permanent
|
||||
banreason?: string;
|
||||
}
|
||||
|
||||
export interface MoveClientRequest {
|
||||
cid: number;
|
||||
cpw?: string;
|
||||
}
|
||||
|
||||
export interface PokeClientRequest {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface MessageRequest {
|
||||
targetmode: 1 | 2 | 3; // 1=client, 2=channel, 3=server
|
||||
target?: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// Ban
|
||||
export interface CreateBanRequest {
|
||||
ip?: string;
|
||||
name?: string;
|
||||
uid?: string;
|
||||
time?: number;
|
||||
banreason?: string;
|
||||
}
|
||||
|
||||
// Token
|
||||
export interface CreateTokenRequest {
|
||||
tokentype: 0 | 1; // 0=server group, 1=channel group
|
||||
tokenid1: number; // group id
|
||||
tokenid2: number; // channel id (for channel group tokens)
|
||||
tokendescription?: string;
|
||||
}
|
||||
|
||||
// Group operations
|
||||
export interface CreateGroupRequest {
|
||||
name: string;
|
||||
type?: number;
|
||||
}
|
||||
|
||||
export interface GroupMemberAction {
|
||||
cldbid: number;
|
||||
}
|
||||
|
||||
// Permission operations
|
||||
export interface SetPermissionRequest {
|
||||
permid?: number;
|
||||
permsid?: string;
|
||||
permvalue: number;
|
||||
permnegated?: number;
|
||||
permskip?: number;
|
||||
}
|
||||
|
||||
// User management (webapp)
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'moderator' | 'viewer';
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
displayName?: string;
|
||||
role?: 'admin' | 'moderator' | 'viewer';
|
||||
enabled?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Generic API response wrapper
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
code?: number;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type UserRole = 'admin' | 'viewer';
|
||||
|
||||
export interface JwtPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Bot Flow Types
|
||||
|
||||
export interface FlowDefinition {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
position: { x: number; y: number };
|
||||
data: NodeData;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string; // 'true' | 'false' for condition nodes
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type NodeType = 'trigger' | 'action' | 'condition' | 'delay' | 'variable' | 'log';
|
||||
|
||||
export type NodeData = TriggerNodeData | ActionNodeData | ConditionNodeData | DelayNodeData | VariableNodeData | LogNodeData;
|
||||
|
||||
// --- Trigger Types ---
|
||||
export type TriggerNodeData =
|
||||
| EventTriggerData
|
||||
| CronTriggerData
|
||||
| WebhookTriggerData
|
||||
| CommandTriggerData;
|
||||
|
||||
export interface EventTriggerData {
|
||||
triggerType: 'event';
|
||||
label: string;
|
||||
eventName: string;
|
||||
filters?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CronTriggerData {
|
||||
triggerType: 'cron';
|
||||
label: string;
|
||||
cronExpression: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface WebhookTriggerData {
|
||||
triggerType: 'webhook';
|
||||
label: string;
|
||||
webhookPath: string;
|
||||
method: 'GET' | 'POST';
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface CommandTriggerData {
|
||||
triggerType: 'command';
|
||||
label: string;
|
||||
commandPrefix: string;
|
||||
commandName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// --- Action Types ---
|
||||
export type ActionNodeData =
|
||||
| KickActionData
|
||||
| BanActionData
|
||||
| MoveActionData
|
||||
| MessageActionData
|
||||
| PokeActionData
|
||||
| ChannelCreateActionData
|
||||
| ChannelEditActionData
|
||||
| ChannelDeleteActionData
|
||||
| GroupAddClientActionData
|
||||
| GroupRemoveClientActionData
|
||||
| WebQueryActionData
|
||||
| WebhookActionData
|
||||
| HttpRequestActionData
|
||||
| AfkMoverActionData
|
||||
| IdleKickerActionData
|
||||
| PokeGroupActionData
|
||||
| RankCheckActionData
|
||||
| TempChannelCleanupActionData
|
||||
| AnimatedChannelActionData;
|
||||
|
||||
export interface KickActionData {
|
||||
actionType: 'kick';
|
||||
label: string;
|
||||
reasonId: 4 | 5;
|
||||
reasonMsg: string;
|
||||
}
|
||||
|
||||
export interface BanActionData {
|
||||
actionType: 'ban';
|
||||
label: string;
|
||||
duration?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MoveActionData {
|
||||
actionType: 'move';
|
||||
label: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface MessageActionData {
|
||||
actionType: 'message';
|
||||
label: string;
|
||||
targetMode: 1 | 2 | 3;
|
||||
target?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PokeActionData {
|
||||
actionType: 'poke';
|
||||
label: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChannelCreateActionData {
|
||||
actionType: 'channelCreate';
|
||||
label: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GroupAddClientActionData {
|
||||
actionType: 'groupAddClient';
|
||||
label: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export interface GroupRemoveClientActionData {
|
||||
actionType: 'groupRemoveClient';
|
||||
label: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export interface WebQueryActionData {
|
||||
actionType: 'webquery';
|
||||
label: string;
|
||||
command: string;
|
||||
params: Record<string, string>;
|
||||
storeAs?: string;
|
||||
}
|
||||
|
||||
export interface WebhookActionData {
|
||||
actionType: 'webhook';
|
||||
label: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
storeAs?: string;
|
||||
}
|
||||
|
||||
export interface ChannelEditActionData {
|
||||
actionType: 'channelEdit';
|
||||
label: string;
|
||||
channelId: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelDeleteActionData {
|
||||
actionType: 'channelDelete';
|
||||
label: string;
|
||||
channelId: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface HttpRequestActionData {
|
||||
actionType: 'httpRequest';
|
||||
label: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
storeAs?: string;
|
||||
}
|
||||
|
||||
export interface AfkMoverActionData {
|
||||
actionType: 'afkMover';
|
||||
label: string;
|
||||
afkChannelId: string;
|
||||
idleThresholdSeconds: number;
|
||||
exemptGroupIds?: string;
|
||||
}
|
||||
|
||||
export interface IdleKickerActionData {
|
||||
actionType: 'idleKicker';
|
||||
label: string;
|
||||
idleThresholdSeconds: number;
|
||||
reason?: string;
|
||||
exemptGroupIds?: string;
|
||||
}
|
||||
|
||||
export interface PokeGroupActionData {
|
||||
actionType: 'pokeGroup';
|
||||
label: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RankCheckActionData {
|
||||
actionType: 'rankCheck';
|
||||
label: string;
|
||||
ranks: string;
|
||||
}
|
||||
|
||||
export interface TempChannelCleanupActionData {
|
||||
actionType: 'tempChannelCleanup';
|
||||
label: string;
|
||||
parentChannelId: string;
|
||||
protectedChannelIds?: string;
|
||||
}
|
||||
|
||||
export interface AnimatedChannelActionData {
|
||||
actionType: 'animatedChannel';
|
||||
label: string;
|
||||
channelId: string;
|
||||
text: string;
|
||||
style: 'scroll' | 'typewriter' | 'bounce' | 'blink' | 'wave' | 'alternateCase';
|
||||
intervalSeconds: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
// --- Condition ---
|
||||
export interface ConditionNodeData {
|
||||
nodeType: 'condition';
|
||||
label: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// --- Delay ---
|
||||
export interface DelayNodeData {
|
||||
nodeType: 'delay';
|
||||
label: string;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
// --- Variable ---
|
||||
export interface VariableNodeData {
|
||||
nodeType: 'variable';
|
||||
label: string;
|
||||
operation: 'set' | 'increment' | 'append';
|
||||
variableName: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// --- Log ---
|
||||
export interface LogNodeData {
|
||||
nodeType: 'log';
|
||||
label: string;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Bot Flow (from API)
|
||||
export interface BotFlowSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
serverConfigId: number;
|
||||
virtualServerId: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
executionCount?: number;
|
||||
lastExecution?: string;
|
||||
}
|
||||
|
||||
export interface BotFlowDetail extends BotFlowSummary {
|
||||
flowData: FlowDefinition;
|
||||
}
|
||||
|
||||
export interface BotExecutionSummary {
|
||||
id: number;
|
||||
flowId: number;
|
||||
triggeredBy: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BotLogEntry {
|
||||
id: number;
|
||||
executionId: number | null;
|
||||
nodeId: string | null;
|
||||
nodeName: string | null;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
data: Record<string, any> | null;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// === Music / Voice Bot Types ===
|
||||
|
||||
export type VoiceBotStatus = 'stopped' | 'starting' | 'connected' | 'playing' | 'paused' | 'error';
|
||||
|
||||
export interface MusicBotSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
serverConfigId: number;
|
||||
serverConfig?: { id: number; name: string; host: string };
|
||||
nickname: string;
|
||||
defaultChannel: string | null;
|
||||
volume: number;
|
||||
autoStart: boolean;
|
||||
status: VoiceBotStatus;
|
||||
nowPlaying: QueueItemInfo | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MusicBotDetail extends MusicBotSummary {
|
||||
channelPassword: string | null;
|
||||
updatedAt: string;
|
||||
playbackProgress: { position: number; duration: number } | null;
|
||||
}
|
||||
|
||||
export interface CreateMusicBotRequest {
|
||||
name: string;
|
||||
serverConfigId: number;
|
||||
nickname?: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
volume?: number;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMusicBotRequest {
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
volume?: number;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
// === Song Types ===
|
||||
|
||||
export interface SongInfo {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
duration: number | null;
|
||||
filePath: string;
|
||||
source: 'local' | 'youtube' | 'url';
|
||||
sourceUrl: string | null;
|
||||
fileSize: number | null;
|
||||
serverConfigId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface QueueItemInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration?: number;
|
||||
source: string;
|
||||
streamUrl?: string;
|
||||
}
|
||||
|
||||
export type RepeatMode = 'off' | 'track' | 'queue';
|
||||
|
||||
export interface PlaybackState {
|
||||
status: VoiceBotStatus;
|
||||
nowPlaying: QueueItemInfo | null;
|
||||
position: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
queue: QueueItemInfo[];
|
||||
currentIndex: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// === Playlist Types ===
|
||||
|
||||
export interface PlaylistSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
musicBotId: number | null;
|
||||
songCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistDetail extends PlaylistSummary {
|
||||
songs: (SongInfo & { position: number })[];
|
||||
}
|
||||
|
||||
// === YouTube Types ===
|
||||
|
||||
export interface YouTubeSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
// === Radio Station Types ===
|
||||
|
||||
export interface RadioStationInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
genre: string | null;
|
||||
imageUrl: string | null;
|
||||
serverConfigId: number;
|
||||
}
|
||||
|
||||
export interface RadioPreset {
|
||||
name: string;
|
||||
url: string;
|
||||
genre: string;
|
||||
}
|
||||
|
||||
export interface YouTubeUrlInfo {
|
||||
type: 'video' | 'playlist';
|
||||
items: YouTubeSearchResult[];
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// TeamSpeak WebQuery API response wrapper
|
||||
export interface TSResponse<T = Record<string, any>[]> {
|
||||
body: T;
|
||||
status: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Virtual Server
|
||||
export interface VirtualServer {
|
||||
virtualserver_id: number;
|
||||
virtualserver_port: number;
|
||||
virtualserver_status: 'online' | 'offline';
|
||||
virtualserver_name: string;
|
||||
virtualserver_clientsonline: number;
|
||||
virtualserver_queryclientsonline: number;
|
||||
virtualserver_maxclients: number;
|
||||
virtualserver_uptime: number;
|
||||
virtualserver_autostart: number;
|
||||
virtualserver_unique_identifier?: string;
|
||||
}
|
||||
|
||||
export interface VirtualServerInfo extends VirtualServer {
|
||||
virtualserver_welcomemessage: string;
|
||||
virtualserver_platform: string;
|
||||
virtualserver_version: string;
|
||||
virtualserver_password: string;
|
||||
virtualserver_hostbanner_url: string;
|
||||
virtualserver_hostbanner_gfx_url: string;
|
||||
virtualserver_hostbanner_gfx_interval: number;
|
||||
virtualserver_hostbanner_mode: number;
|
||||
virtualserver_hostbutton_tooltip: string;
|
||||
virtualserver_hostbutton_url: string;
|
||||
virtualserver_hostbutton_gfx_url: string;
|
||||
virtualserver_hostmessage: string;
|
||||
virtualserver_hostmessage_mode: number;
|
||||
virtualserver_default_server_group: number;
|
||||
virtualserver_default_channel_group: number;
|
||||
virtualserver_default_channel_admin_group: number;
|
||||
virtualserver_codec_encryption_mode: number;
|
||||
virtualserver_antiflood_points_tick_reduce: number;
|
||||
virtualserver_antiflood_points_needed_command_block: number;
|
||||
virtualserver_antiflood_points_needed_ip_block: number;
|
||||
virtualserver_log_client: number;
|
||||
virtualserver_log_query: number;
|
||||
virtualserver_log_channel: number;
|
||||
virtualserver_log_permissions: number;
|
||||
virtualserver_log_server: number;
|
||||
virtualserver_log_filetransfer: number;
|
||||
virtualserver_icon_id: number;
|
||||
virtualserver_reserved_slots: number;
|
||||
virtualserver_total_packetloss_speech: number;
|
||||
virtualserver_total_packetloss_keepalive: number;
|
||||
virtualserver_total_packetloss_control: number;
|
||||
virtualserver_total_packetloss_total: number;
|
||||
virtualserver_total_ping: number;
|
||||
virtualserver_created: number;
|
||||
virtualserver_channel_temp_delete_delay_default: number;
|
||||
virtualserver_min_client_version: number;
|
||||
}
|
||||
|
||||
// Channel
|
||||
export interface Channel {
|
||||
cid: number;
|
||||
pid: number;
|
||||
channel_order: number;
|
||||
channel_name: string;
|
||||
channel_topic: string;
|
||||
total_clients: number;
|
||||
channel_needed_subscribe_power: number;
|
||||
channel_flag_default: number;
|
||||
channel_flag_password: number;
|
||||
channel_flag_permanent: number;
|
||||
channel_flag_semi_permanent: number;
|
||||
channel_codec: number;
|
||||
channel_codec_quality: number;
|
||||
channel_icon_id: number;
|
||||
channel_maxclients: number;
|
||||
channel_maxfamilyclients: number;
|
||||
seconds_empty: number;
|
||||
}
|
||||
|
||||
export interface ChannelInfo extends Channel {
|
||||
channel_description: string;
|
||||
channel_password: string;
|
||||
channel_codec_latency_factor: number;
|
||||
channel_flag_temporary: number;
|
||||
channel_flag_maxclients_unlimited: number;
|
||||
channel_flag_maxfamilyclients_unlimited: number;
|
||||
channel_flag_maxfamilyclients_inherited: number;
|
||||
channel_needed_talk_power: number;
|
||||
channel_forced_silence: number;
|
||||
channel_filepath: string;
|
||||
channel_security_salt: string;
|
||||
channel_delete_delay: number;
|
||||
channel_unique_identifier: string;
|
||||
}
|
||||
|
||||
// Channel tree node (frontend representation)
|
||||
export interface ChannelTreeNode extends Channel {
|
||||
children: ChannelTreeNode[];
|
||||
clients?: Client[];
|
||||
}
|
||||
|
||||
// Client (online)
|
||||
export interface Client {
|
||||
clid: number;
|
||||
cid: number;
|
||||
client_database_id: number;
|
||||
client_nickname: string;
|
||||
client_type: number; // 0=normal, 1=serverquery
|
||||
client_unique_identifier: string;
|
||||
client_away: number;
|
||||
client_away_message: string;
|
||||
client_flag_talking: number;
|
||||
client_input_muted: number;
|
||||
client_output_muted: number;
|
||||
client_input_hardware: number;
|
||||
client_output_hardware: number;
|
||||
client_talk_power: number;
|
||||
client_is_talker: number;
|
||||
client_is_priority_speaker: number;
|
||||
client_is_recording: number;
|
||||
client_is_channel_commander: number;
|
||||
client_servergroups: string;
|
||||
client_channel_group_id: number;
|
||||
client_channel_group_inherited_channel_id: number;
|
||||
client_country: string;
|
||||
client_idle_time: number;
|
||||
client_created: number;
|
||||
client_lastconnected: number;
|
||||
connection_client_ip?: string;
|
||||
}
|
||||
|
||||
export interface ClientInfo extends Client {
|
||||
client_version: string;
|
||||
client_platform: string;
|
||||
client_login_name: string;
|
||||
client_totalconnections: number;
|
||||
client_description: string;
|
||||
client_month_bytes_uploaded: number;
|
||||
client_month_bytes_downloaded: number;
|
||||
client_total_bytes_uploaded: number;
|
||||
client_total_bytes_downloaded: number;
|
||||
connection_connected_time: number;
|
||||
connection_bandwidth_sent_last_second_total: number;
|
||||
connection_bandwidth_received_last_second_total: number;
|
||||
}
|
||||
|
||||
// Client Database Entry
|
||||
export interface ClientDBEntry {
|
||||
cldbid: number;
|
||||
client_unique_identifier: string;
|
||||
client_nickname: string;
|
||||
client_created: number;
|
||||
client_lastconnected: number;
|
||||
client_totalconnections: number;
|
||||
client_description: string;
|
||||
client_lastip: string;
|
||||
}
|
||||
|
||||
// Server Group
|
||||
export interface ServerGroup {
|
||||
sgid: number;
|
||||
name: string;
|
||||
type: number;
|
||||
iconid: number;
|
||||
savedb: number;
|
||||
sortid: number;
|
||||
namemode: number;
|
||||
n_modifyp: number;
|
||||
n_member_addp: number;
|
||||
n_member_removep: number;
|
||||
}
|
||||
|
||||
// Channel Group
|
||||
export interface ChannelGroup {
|
||||
cgid: number;
|
||||
name: string;
|
||||
type: number;
|
||||
iconid: number;
|
||||
savedb: number;
|
||||
sortid: number;
|
||||
namemode: number;
|
||||
n_modifyp: number;
|
||||
n_member_addp: number;
|
||||
n_member_removep: number;
|
||||
}
|
||||
|
||||
// Permission
|
||||
export interface Permission {
|
||||
permid: number;
|
||||
permsid: string;
|
||||
permname: string;
|
||||
permdesc: string;
|
||||
}
|
||||
|
||||
export interface PermissionAssignment {
|
||||
permid: number;
|
||||
permsid?: string;
|
||||
permvalue: number;
|
||||
permnegated: number;
|
||||
permskip: number;
|
||||
}
|
||||
|
||||
// Ban
|
||||
export interface Ban {
|
||||
banid: number;
|
||||
ip: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
mytsid: string;
|
||||
lastnickname: string;
|
||||
created: number;
|
||||
duration: number;
|
||||
invokername: string;
|
||||
invokercldbid: number;
|
||||
invokeruid: string;
|
||||
reason: string;
|
||||
enforcements: number;
|
||||
}
|
||||
|
||||
// Privilege Key / Token
|
||||
export interface PrivilegeKey {
|
||||
token: string;
|
||||
token_type: number; // 0=server group, 1=channel group
|
||||
token_id1: number;
|
||||
token_id2: number;
|
||||
token_created: number;
|
||||
token_description: string;
|
||||
token_customset: string;
|
||||
}
|
||||
|
||||
// Complaint
|
||||
export interface Complaint {
|
||||
tcldbid: number;
|
||||
tname: string;
|
||||
fcldbid: number;
|
||||
fname: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Offline Message
|
||||
export interface OfflineMessage {
|
||||
msgid: number;
|
||||
cluid: string;
|
||||
subject: string;
|
||||
message?: string;
|
||||
timestamp: number;
|
||||
flag_read: number;
|
||||
}
|
||||
|
||||
// Log Entry
|
||||
export interface LogEntry {
|
||||
last_pos: number;
|
||||
file_size: number;
|
||||
l: string; // log line string
|
||||
}
|
||||
|
||||
// Server Connection Info
|
||||
export interface ConnectionInfo {
|
||||
connection_filetransfer_bandwidth_sent: number;
|
||||
connection_filetransfer_bandwidth_received: number;
|
||||
connection_filetransfer_bytes_sent_total: number;
|
||||
connection_filetransfer_bytes_received_total: number;
|
||||
connection_packets_sent_total: number;
|
||||
connection_bytes_sent_total: number;
|
||||
connection_packets_received_total: number;
|
||||
connection_bytes_received_total: number;
|
||||
connection_bandwidth_sent_last_second_total: number;
|
||||
connection_bandwidth_sent_last_minute_total: number;
|
||||
connection_bandwidth_received_last_second_total: number;
|
||||
connection_bandwidth_received_last_minute_total: number;
|
||||
connection_connected_time: number;
|
||||
connection_packetloss_total: number;
|
||||
connection_ping: number;
|
||||
}
|
||||
|
||||
// Host Info
|
||||
export interface HostInfo {
|
||||
instance_uptime: number;
|
||||
host_timestamp_utc: number;
|
||||
virtualservers_running_total: number;
|
||||
virtualservers_total_maxclients: number;
|
||||
virtualservers_total_clients_online: number;
|
||||
virtualservers_total_channels_online: number;
|
||||
connection_filetransfer_bandwidth_sent: number;
|
||||
connection_filetransfer_bandwidth_received: number;
|
||||
connection_packets_sent_total: number;
|
||||
connection_bytes_sent_total: number;
|
||||
connection_packets_received_total: number;
|
||||
connection_bytes_received_total: number;
|
||||
connection_bandwidth_sent_last_second_total: number;
|
||||
connection_bandwidth_sent_last_minute_total: number;
|
||||
connection_bandwidth_received_last_second_total: number;
|
||||
connection_bandwidth_received_last_minute_total: number;
|
||||
}
|
||||
|
||||
// Instance Info
|
||||
export interface InstanceInfo {
|
||||
serverinstance_database_version: number;
|
||||
serverinstance_filetransfer_port: number;
|
||||
serverinstance_max_download_total_bandwidth: number;
|
||||
serverinstance_max_upload_total_bandwidth: number;
|
||||
serverinstance_guest_serverquery_group: number;
|
||||
serverinstance_serverquery_flood_commands: number;
|
||||
serverinstance_serverquery_flood_time: number;
|
||||
serverinstance_serverquery_ban_time: number;
|
||||
serverinstance_template_serveradmin_group: number;
|
||||
serverinstance_template_serverdefault_group: number;
|
||||
serverinstance_template_channeladmin_group: number;
|
||||
serverinstance_template_channeldefault_group: number;
|
||||
serverinstance_permissions_version: number;
|
||||
}
|
||||
|
||||
// Version
|
||||
export interface ServerVersion {
|
||||
version: string;
|
||||
build: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
// File Transfer
|
||||
export interface FileEntry {
|
||||
cid: number;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
datetime: number;
|
||||
type: number; // 0=file, 1=directory
|
||||
}
|
||||
|
||||
// Temporary Password
|
||||
export interface TempPassword {
|
||||
nickname: string;
|
||||
uid: string;
|
||||
desc: string;
|
||||
pw_clear: string;
|
||||
start: number;
|
||||
end: number;
|
||||
tcid: number;
|
||||
}
|
||||
|
||||
// Codecs
|
||||
export enum Codec {
|
||||
SPEEX_NARROWBAND = 0,
|
||||
SPEEX_WIDEBAND = 1,
|
||||
SPEEX_ULTRAWIDEBAND = 2,
|
||||
CELT_MONO = 3,
|
||||
OPUS_VOICE = 4,
|
||||
OPUS_MUSIC = 5,
|
||||
}
|
||||
|
||||
export const CodecNames: Record<number, string> = {
|
||||
0: 'Speex Narrowband',
|
||||
1: 'Speex Wideband',
|
||||
2: 'Speex Ultra-Wideband',
|
||||
3: 'CELT Mono',
|
||||
4: 'Opus Voice',
|
||||
5: 'Opus Music',
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// TeamSpeak ServerQuery string escaping/unescaping
|
||||
// The query protocol requires special character encoding
|
||||
|
||||
const ESCAPE_MAP: [string, string][] = [
|
||||
['\\', '\\\\'],
|
||||
['/', '\\/'],
|
||||
[' ', '\\s'],
|
||||
['|', '\\p'],
|
||||
['\n', '\\n'],
|
||||
['\r', '\\r'],
|
||||
['\t', '\\t'],
|
||||
['\b', '\\b'],
|
||||
['\f', '\\f'],
|
||||
];
|
||||
|
||||
export function tsEscape(str: string): string {
|
||||
let result = str;
|
||||
for (const [char, escaped] of ESCAPE_MAP) {
|
||||
result = result.split(char).join(escaped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function tsUnescape(str: string): string {
|
||||
let result = str;
|
||||
for (const [char, escaped] of [...ESCAPE_MAP].reverse()) {
|
||||
result = result.split(escaped).join(char);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse a ServerQuery response line into key-value pairs
|
||||
export function parseQueryResponse(line: string): Record<string, string>[] {
|
||||
return line.split('|').map((entry) => {
|
||||
const params: Record<string, string> = {};
|
||||
for (const part of entry.split(' ')) {
|
||||
const eqIndex = part.indexOf('=');
|
||||
if (eqIndex === -1) {
|
||||
params[part] = '';
|
||||
} else {
|
||||
const key = part.substring(0, eqIndex);
|
||||
const value = tsUnescape(part.substring(eqIndex + 1));
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TeamSpeak 6 Manager</title>
|
||||
<script>
|
||||
// Apply persisted theme before React renders to prevent flash
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem('ts6-ui') || '{}');
|
||||
var theme = (stored.state && stored.state.theme) || 'dark';
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch(e) { document.documentElement.classList.add('dark'); }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@ts6/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ts6/common": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"@tanstack/react-table": "^8.20.0",
|
||||
"zustand": "^5.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"@xyflow/react": "^12.3.0",
|
||||
"recharts": "^2.13.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"zod": "^3.23.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"sonner": "^1.7.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-progress": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { PageLoader } from '@/components/shared/LoadingSpinner';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAdmin = useAuthStore((s) => s.isAdmin());
|
||||
if (!isAdmin) return <Navigate to="/dashboard" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Lazy-loaded pages
|
||||
const Login = lazy(() => import('@/pages/Login'));
|
||||
const Dashboard = lazy(() => import('@/pages/Dashboard'));
|
||||
const VirtualServers = lazy(() => import('@/pages/VirtualServers'));
|
||||
const Channels = lazy(() => import('@/pages/Channels'));
|
||||
const Clients = lazy(() => import('@/pages/Clients'));
|
||||
const ServerGroups = lazy(() => import('@/pages/ServerGroups'));
|
||||
const ChannelGroups = lazy(() => import('@/pages/ChannelGroups'));
|
||||
const Permissions = lazy(() => import('@/pages/Permissions'));
|
||||
const Bans = lazy(() => import('@/pages/Bans'));
|
||||
const Tokens = lazy(() => import('@/pages/Tokens'));
|
||||
const Files = lazy(() => import('@/pages/Files'));
|
||||
const Complaints = lazy(() => import('@/pages/Complaints'));
|
||||
const Messages = lazy(() => import('@/pages/Messages'));
|
||||
const ServerLogs = lazy(() => import('@/pages/ServerLogs'));
|
||||
const Instance = lazy(() => import('@/pages/Instance'));
|
||||
const BotList = lazy(() => import('@/pages/BotList'));
|
||||
const BotEditor = lazy(() => import('@/pages/BotEditor'));
|
||||
const MusicBots = lazy(() => import('@/pages/MusicBots'));
|
||||
const Settings = lazy(() => import('@/pages/Settings'));
|
||||
const NotFound = lazy(() => import('@/pages/NotFound'));
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/servers" element={<AdminRoute><VirtualServers /></AdminRoute>} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
<Route path="/server-groups" element={<AdminRoute><ServerGroups /></AdminRoute>} />
|
||||
<Route path="/channel-groups" element={<AdminRoute><ChannelGroups /></AdminRoute>} />
|
||||
<Route path="/permissions" element={<AdminRoute><Permissions /></AdminRoute>} />
|
||||
<Route path="/bans" element={<AdminRoute><Bans /></AdminRoute>} />
|
||||
<Route path="/tokens" element={<AdminRoute><Tokens /></AdminRoute>} />
|
||||
<Route path="/files" element={<AdminRoute><Files /></AdminRoute>} />
|
||||
<Route path="/complaints" element={<AdminRoute><Complaints /></AdminRoute>} />
|
||||
<Route path="/messages" element={<AdminRoute><Messages /></AdminRoute>} />
|
||||
<Route path="/logs" element={<AdminRoute><ServerLogs /></AdminRoute>} />
|
||||
<Route path="/instance" element={<AdminRoute><Instance /></AdminRoute>} />
|
||||
<Route path="/bots" element={<AdminRoute><BotList /></AdminRoute>} />
|
||||
<Route path="/bots/:botId" element={<AdminRoute><BotEditor /></AdminRoute>} />
|
||||
<Route path="/music-bots" element={<AdminRoute><MusicBots /></AdminRoute>} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from './client';
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post('/auth/login', { username, password }).then((r) => r.data),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
api.post('/auth/refresh', { refreshToken }).then((r) => r.data),
|
||||
|
||||
logout: (refreshToken: string) =>
|
||||
api.post('/auth/logout', { refreshToken }),
|
||||
|
||||
me: () => api.get('/auth/me').then((r) => r.data),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from './client';
|
||||
|
||||
const base = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/bans`;
|
||||
|
||||
export const bansApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(base(configId, sid)).then((r) => r.data),
|
||||
add: (configId: number, sid: number, data: any) =>
|
||||
api.post(base(configId, sid), data).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, banid: number) =>
|
||||
api.delete(`${base(configId, sid)}/${banid}`),
|
||||
deleteAll: (configId: number, sid: number) =>
|
||||
api.delete(base(configId, sid)),
|
||||
};
|
||||
|
||||
export const tokensApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/tokens`).then((r) => r.data),
|
||||
add: (configId: number, sid: number, data: any) =>
|
||||
api.post(`/servers/${configId}/vs/${sid}/tokens`, data).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, token: string) =>
|
||||
api.delete(`/servers/${configId}/vs/${sid}/tokens/${encodeURIComponent(token)}`),
|
||||
};
|
||||
|
||||
export const complaintsApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/complaints`).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, tcldbid: number, fcldbid: number) =>
|
||||
api.delete(`/servers/${configId}/vs/${sid}/complaints/${tcldbid}/${fcldbid}`),
|
||||
};
|
||||
|
||||
export const messagesApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/messages`).then((r) => r.data),
|
||||
get: (configId: number, sid: number, msgid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/messages/${msgid}`).then((r) => r.data),
|
||||
send: (configId: number, sid: number, data: any) =>
|
||||
api.post(`/servers/${configId}/vs/${sid}/messages`, data).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, msgid: number) =>
|
||||
api.delete(`/servers/${configId}/vs/${sid}/messages/${msgid}`),
|
||||
};
|
||||
|
||||
export const logsApi = {
|
||||
get: (configId: number, sid: number, lines = 100) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/logs`, { params: { lines, reverse: 1 } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const filesApi = {
|
||||
list: (configId: number, sid: number, cid: number, path = '/') =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/files/${cid}`, { params: { path } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const permissionsApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/permissions`).then((r) => r.data),
|
||||
find: (configId: number, sid: number, permsid: string) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/permissions/find`, { params: { permsid } }).then((r) => r.data),
|
||||
overview: (configId: number, sid: number, cldbid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/permissions/overview/${cldbid}`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import api from './client';
|
||||
|
||||
export const botsApi = {
|
||||
list: () => api.get('/bots').then((r) => r.data),
|
||||
get: (id: number) => api.get(`/bots/${id}`).then((r) => r.data),
|
||||
create: (data: any) => api.post('/bots', data).then((r) => r.data),
|
||||
update: (id: number, data: any) => api.put(`/bots/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => api.delete(`/bots/${id}`),
|
||||
enable: (id: number) => api.post(`/bots/${id}/enable`).then((r) => r.data),
|
||||
disable: (id: number) => api.post(`/bots/${id}/disable`).then((r) => r.data),
|
||||
executions: (id: number) => api.get(`/bots/${id}/executions`).then((r) => r.data),
|
||||
executionLogs: (botId: number, execId: number) =>
|
||||
api.get(`/bots/${botId}/executions/${execId}/logs`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
list: () => api.get('/users').then((r) => r.data),
|
||||
create: (data: any) => api.post('/users', data).then((r) => r.data),
|
||||
update: (id: number, data: any) => api.put(`/users/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/users/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import api from './client';
|
||||
|
||||
const base = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/channels`;
|
||||
|
||||
export const channelsApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(base(configId, sid)).then((r) => r.data),
|
||||
get: (configId: number, sid: number, cid: number) =>
|
||||
api.get(`${base(configId, sid)}/${cid}`).then((r) => r.data),
|
||||
create: (configId: number, sid: number, data: any) =>
|
||||
api.post(base(configId, sid), data).then((r) => r.data),
|
||||
edit: (configId: number, sid: number, cid: number, data: any) =>
|
||||
api.put(`${base(configId, sid)}/${cid}`, data).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, cid: number) =>
|
||||
api.delete(`${base(configId, sid)}/${cid}`),
|
||||
move: (configId: number, sid: number, cid: number, data: any) =>
|
||||
api.post(`${base(configId, sid)}/${cid}/move`, data).then((r) => r.data),
|
||||
permissions: (configId: number, sid: number, cid: number) =>
|
||||
api.get(`${base(configId, sid)}/${cid}/permissions`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const { refreshToken, setTokens, logout } = useAuthStore.getState();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const res = await axios.post('/api/auth/refresh', { refreshToken });
|
||||
setTokens(res.data.accessToken, res.data.refreshToken);
|
||||
original.headers.Authorization = `Bearer ${res.data.accessToken}`;
|
||||
return api(original);
|
||||
} catch {
|
||||
logout();
|
||||
}
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,23 @@
|
||||
import api from './client';
|
||||
|
||||
const base = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/clients`;
|
||||
|
||||
export const clientsApi = {
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(base(configId, sid)).then((r) => r.data),
|
||||
get: (configId: number, sid: number, clid: number) =>
|
||||
api.get(`${base(configId, sid)}/${clid}`).then((r) => r.data),
|
||||
database: (configId: number, sid: number, start = 0, duration = 100) =>
|
||||
api.get(`${base(configId, sid)}/database`, { params: { start, duration } }).then((r) => r.data),
|
||||
kick: (configId: number, sid: number, clid: number, reasonid: number, reasonmsg?: string) =>
|
||||
api.post(`${base(configId, sid)}/${clid}/kick`, { reasonid, reasonmsg }).then((r) => r.data),
|
||||
ban: (configId: number, sid: number, clid: number, time?: number, banreason?: string) =>
|
||||
api.post(`${base(configId, sid)}/${clid}/ban`, { time, banreason }).then((r) => r.data),
|
||||
move: (configId: number, sid: number, clid: number, cid: number) =>
|
||||
api.post(`${base(configId, sid)}/${clid}/move`, { cid }).then((r) => r.data),
|
||||
poke: (configId: number, sid: number, clid: number, msg: string) =>
|
||||
api.post(`${base(configId, sid)}/${clid}/poke`, { msg }).then((r) => r.data),
|
||||
message: (configId: number, sid: number, clid: number, msg: string) =>
|
||||
api.post(`${base(configId, sid)}/${clid}/message`, { msg }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import api from './client';
|
||||
|
||||
export const dashboardApi = {
|
||||
get: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/vs/${sid}/dashboard`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import api from './client';
|
||||
|
||||
const base = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/files`;
|
||||
|
||||
export const filesApi = {
|
||||
list: (configId: number, sid: number, cid: number, path = '/') =>
|
||||
api.get(`${base(configId, sid)}/${cid}`, { params: { path } }).then((r) => r.data),
|
||||
createDir: (configId: number, sid: number, cid: number, dirname: string) =>
|
||||
api.post(`${base(configId, sid)}/${cid}/mkdir`, { dirname }).then((r) => r.data),
|
||||
delete: (configId: number, sid: number, cid: number, name: string) =>
|
||||
api.delete(`${base(configId, sid)}/${cid}/file`, { data: { name } }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import api from './client';
|
||||
|
||||
const sgBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/server-groups`;
|
||||
const cgBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/channel-groups`;
|
||||
|
||||
export const groupsApi = {
|
||||
// Server groups
|
||||
serverGroups: (configId: number, sid: number) =>
|
||||
api.get(sgBase(configId, sid)).then((r) => r.data),
|
||||
createServerGroup: (configId: number, sid: number, name: string) =>
|
||||
api.post(sgBase(configId, sid), { name }).then((r) => r.data),
|
||||
deleteServerGroup: (configId: number, sid: number, sgid: number) =>
|
||||
api.delete(`${sgBase(configId, sid)}/${sgid}`),
|
||||
renameServerGroup: (configId: number, sid: number, sgid: number, name: string) =>
|
||||
api.put(`${sgBase(configId, sid)}/${sgid}`, { name }).then((r) => r.data),
|
||||
serverGroupMembers: (configId: number, sid: number, sgid: number) =>
|
||||
api.get(`${sgBase(configId, sid)}/${sgid}/members`).then((r) => r.data),
|
||||
addServerGroupMember: (configId: number, sid: number, sgid: number, cldbid: number) =>
|
||||
api.post(`${sgBase(configId, sid)}/${sgid}/members`, { cldbid }).then((r) => r.data),
|
||||
removeServerGroupMember: (configId: number, sid: number, sgid: number, cldbid: number) =>
|
||||
api.delete(`${sgBase(configId, sid)}/${sgid}/members/${cldbid}`),
|
||||
serverGroupPerms: (configId: number, sid: number, sgid: number) =>
|
||||
api.get(`${sgBase(configId, sid)}/${sgid}/permissions`).then((r) => r.data),
|
||||
|
||||
// Channel groups
|
||||
channelGroups: (configId: number, sid: number) =>
|
||||
api.get(cgBase(configId, sid)).then((r) => r.data),
|
||||
createChannelGroup: (configId: number, sid: number, name: string) =>
|
||||
api.post(cgBase(configId, sid), { name }).then((r) => r.data),
|
||||
deleteChannelGroup: (configId: number, sid: number, cgid: number) =>
|
||||
api.delete(`${cgBase(configId, sid)}/${cgid}`),
|
||||
channelGroupPerms: (configId: number, sid: number, cgid: number) =>
|
||||
api.get(`${cgBase(configId, sid)}/${cgid}/permissions`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import api from './client';
|
||||
|
||||
// === Music Bot API ===
|
||||
|
||||
export const musicBotsApi = {
|
||||
list: () => api.get('/music-bots').then((r) => r.data),
|
||||
get: (id: number) => api.get(`/music-bots/${id}`).then((r) => r.data),
|
||||
create: (data: any) => api.post('/music-bots', data).then((r) => r.data),
|
||||
update: (id: number, data: any) => api.put(`/music-bots/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => api.delete(`/music-bots/${id}`),
|
||||
start: (id: number) => api.post(`/music-bots/${id}/start`).then((r) => r.data),
|
||||
stop: (id: number) => api.post(`/music-bots/${id}/stop`).then((r) => r.data),
|
||||
restart: (id: number) => api.post(`/music-bots/${id}/restart`).then((r) => r.data),
|
||||
|
||||
// Playback
|
||||
playRadio: (id: number, stationId: number) => api.post(`/music-bots/${id}/play-radio`, { stationId }).then((r) => r.data),
|
||||
play: (id: number, songId: number) => api.post(`/music-bots/${id}/play`, { songId }).then((r) => r.data),
|
||||
pause: (id: number) => api.post(`/music-bots/${id}/pause`).then((r) => r.data),
|
||||
resume: (id: number) => api.post(`/music-bots/${id}/resume`).then((r) => r.data),
|
||||
stopPlayback: (id: number) => api.post(`/music-bots/${id}/stop-playback`).then((r) => r.data),
|
||||
skip: (id: number) => api.post(`/music-bots/${id}/skip`).then((r) => r.data),
|
||||
previous: (id: number) => api.post(`/music-bots/${id}/previous`).then((r) => r.data),
|
||||
seek: (id: number, seconds: number) => api.post(`/music-bots/${id}/seek`, { seconds }).then((r) => r.data),
|
||||
volume: (id: number, volume: number) => api.post(`/music-bots/${id}/volume`, { volume }).then((r) => r.data),
|
||||
state: (id: number) => api.get(`/music-bots/${id}/state`).then((r) => r.data),
|
||||
|
||||
// Queue
|
||||
queue: (id: number) => api.get(`/music-bots/${id}/queue`).then((r) => r.data),
|
||||
enqueue: (id: number, songId: number) => api.post(`/music-bots/${id}/queue`, { songId }).then((r) => r.data),
|
||||
loadPlaylist: (id: number, playlistId: number, clearFirst?: boolean) =>
|
||||
api.post(`/music-bots/${id}/queue/playlist`, { playlistId, clearFirst }).then((r) => r.data),
|
||||
removeFromQueue: (id: number, index: number) => api.delete(`/music-bots/${id}/queue/${index}`).then((r) => r.data),
|
||||
clearQueue: (id: number) => api.delete(`/music-bots/${id}/queue`).then((r) => r.data),
|
||||
shuffle: (id: number, enabled: boolean) => api.post(`/music-bots/${id}/queue/shuffle`, { enabled }).then((r) => r.data),
|
||||
repeat: (id: number, mode: string) => api.post(`/music-bots/${id}/queue/repeat`, { mode }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// === Music Library API ===
|
||||
|
||||
export const musicLibraryApi = {
|
||||
songs: (configId: number) => api.get(`/servers/${configId}/music-library/songs`).then((r) => r.data),
|
||||
upload: (configId: number, formData: FormData) =>
|
||||
api.post(`/servers/${configId}/music-library/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 300000, // 5 min for large uploads
|
||||
}).then((r) => r.data),
|
||||
deleteSong: (configId: number, songId: number) =>
|
||||
api.delete(`/servers/${configId}/music-library/songs/${songId}`).then((r) => r.data),
|
||||
youtubeSearch: (configId: number, query: string) =>
|
||||
api.post(`/servers/${configId}/music-library/youtube/search`, { query }).then((r) => r.data),
|
||||
youtubeDownload: (configId: number, url: string) =>
|
||||
api.post(`/servers/${configId}/music-library/youtube/download`, { url }).then((r) => r.data),
|
||||
youtubeInfo: (configId: number, url: string) =>
|
||||
api.post(`/servers/${configId}/music-library/youtube/info`, { url }).then((r) => r.data),
|
||||
youtubeDownloadBatch: (configId: number, urls: string[]) =>
|
||||
api.post(`/servers/${configId}/music-library/youtube/download-batch`, { urls }, { timeout: 600000 }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// === Radio Station API ===
|
||||
|
||||
export const radioStationsApi = {
|
||||
list: (configId: number) => api.get(`/servers/${configId}/radio-stations`).then((r) => r.data),
|
||||
presets: (configId: number) => api.get(`/servers/${configId}/radio-stations/presets`).then((r) => r.data),
|
||||
create: (configId: number, data: { name: string; url: string; genre?: string }) =>
|
||||
api.post(`/servers/${configId}/radio-stations`, data).then((r) => r.data),
|
||||
delete: (configId: number, id: number) =>
|
||||
api.delete(`/servers/${configId}/radio-stations/${id}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// === Playlist API ===
|
||||
|
||||
export const playlistsApi = {
|
||||
list: (musicBotId?: number) =>
|
||||
api.get('/playlists', { params: musicBotId ? { musicBotId } : undefined }).then((r) => r.data),
|
||||
get: (id: number) => api.get(`/playlists/${id}`).then((r) => r.data),
|
||||
create: (data: { name: string; musicBotId?: number }) => api.post('/playlists', data).then((r) => r.data),
|
||||
update: (id: number, data: any) => api.put(`/playlists/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => api.delete(`/playlists/${id}`),
|
||||
addSong: (id: number, songId: number) => api.post(`/playlists/${id}/songs`, { songId }).then((r) => r.data),
|
||||
removeSong: (id: number, songId: number) => api.delete(`/playlists/${id}/songs/${songId}`).then((r) => r.data),
|
||||
reorder: (id: number, songIds: number[]) => api.put(`/playlists/${id}/songs/reorder`, { songIds }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import api from './client';
|
||||
|
||||
const base = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/permissions`;
|
||||
const sgBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/server-groups`;
|
||||
const cgBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/channel-groups`;
|
||||
const chBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/channels`;
|
||||
const clBase = (configId: number, sid: number) =>
|
||||
`/servers/${configId}/vs/${sid}/clients`;
|
||||
|
||||
export const permissionsApi = {
|
||||
// All known permissions (definitions)
|
||||
list: (configId: number, sid: number) =>
|
||||
api.get(base(configId, sid)).then((r) => r.data),
|
||||
|
||||
// Server Group permissions
|
||||
serverGroupPerms: (configId: number, sid: number, sgid: number) =>
|
||||
api.get(`${sgBase(configId, sid)}/${sgid}/permissions`).then((r) => r.data),
|
||||
addServerGroupPerm: (configId: number, sid: number, sgid: number, data: any) =>
|
||||
api.put(`${sgBase(configId, sid)}/${sgid}/permissions`, data).then((r) => r.data),
|
||||
delServerGroupPerm: (configId: number, sid: number, sgid: number, data: any) =>
|
||||
api.delete(`${sgBase(configId, sid)}/${sgid}/permissions`, { data }).then((r) => r.data),
|
||||
|
||||
// Channel Group permissions
|
||||
channelGroupPerms: (configId: number, sid: number, cgid: number) =>
|
||||
api.get(`${cgBase(configId, sid)}/${cgid}/permissions`).then((r) => r.data),
|
||||
addChannelGroupPerm: (configId: number, sid: number, cgid: number, data: any) =>
|
||||
api.put(`${cgBase(configId, sid)}/${cgid}/permissions`, data).then((r) => r.data),
|
||||
delChannelGroupPerm: (configId: number, sid: number, cgid: number, data: any) =>
|
||||
api.delete(`${cgBase(configId, sid)}/${cgid}/permissions`, { data }).then((r) => r.data),
|
||||
|
||||
// Channel permissions
|
||||
channelPerms: (configId: number, sid: number, cid: number) =>
|
||||
api.get(`${chBase(configId, sid)}/${cid}/permissions`).then((r) => r.data),
|
||||
addChannelPerm: (configId: number, sid: number, cid: number, data: any) =>
|
||||
api.put(`${chBase(configId, sid)}/${cid}/permissions`, data).then((r) => r.data),
|
||||
delChannelPerm: (configId: number, sid: number, cid: number, data: any) =>
|
||||
api.delete(`${chBase(configId, sid)}/${cid}/permissions`, { data }).then((r) => r.data),
|
||||
|
||||
// Client permissions
|
||||
clientPerms: (configId: number, sid: number, cldbid: number) =>
|
||||
api.get(`${clBase(configId, sid)}/${cldbid}/permissions`).then((r) => r.data),
|
||||
|
||||
// Server groups & channel groups list
|
||||
serverGroups: (configId: number, sid: number) =>
|
||||
api.get(sgBase(configId, sid)).then((r) => r.data),
|
||||
channelGroups: (configId: number, sid: number) =>
|
||||
api.get(cgBase(configId, sid)).then((r) => r.data),
|
||||
|
||||
// Channels and clients for entity selectors
|
||||
channels: (configId: number, sid: number) =>
|
||||
api.get(chBase(configId, sid)).then((r) => r.data),
|
||||
clients: (configId: number, sid: number) =>
|
||||
api.get(clBase(configId, sid)).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import api from './client';
|
||||
|
||||
export const serversApi = {
|
||||
list: () => api.get('/servers').then((r) => r.data),
|
||||
get: (id: number) => api.get(`/servers/${id}`).then((r) => r.data),
|
||||
create: (data: any) => api.post('/servers', data).then((r) => r.data),
|
||||
update: (id: number, data: any) => api.put(`/servers/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => api.delete(`/servers/${id}`),
|
||||
test: (id: number) => api.post(`/servers/${id}/test`).then((r) => r.data),
|
||||
|
||||
// Virtual servers
|
||||
listVirtual: (configId: number) =>
|
||||
api.get(`/servers/${configId}/virtual-servers`).then((r) => r.data),
|
||||
getVirtualInfo: (configId: number, sid: number) =>
|
||||
api.get(`/servers/${configId}/virtual-servers/${sid}/info`).then((r) => r.data),
|
||||
editVirtual: (configId: number, sid: number, data: any) =>
|
||||
api.put(`/servers/${configId}/virtual-servers/${sid}`, data).then((r) => r.data),
|
||||
createVirtual: (configId: number, data: any) =>
|
||||
api.post(`/servers/${configId}/virtual-servers`, data).then((r) => r.data),
|
||||
startVirtual: (configId: number, sid: number) =>
|
||||
api.post(`/servers/${configId}/virtual-servers/${sid}/start`).then((r) => r.data),
|
||||
stopVirtual: (configId: number, sid: number) =>
|
||||
api.post(`/servers/${configId}/virtual-servers/${sid}/stop`).then((r) => r.data),
|
||||
deleteVirtual: (configId: number, sid: number) =>
|
||||
api.delete(`/servers/${configId}/virtual-servers/${sid}`),
|
||||
createSnapshot: (configId: number, sid: number) =>
|
||||
api.post(`/servers/${configId}/virtual-servers/${sid}/snapshot`).then((r) => r.data),
|
||||
|
||||
// Instance
|
||||
instanceInfo: (configId: number) =>
|
||||
api.get(`/servers/${configId}/instance`).then((r) => r.data),
|
||||
instanceEdit: (configId: number, data: any) =>
|
||||
api.put(`/servers/${configId}/instance`, data).then((r) => r.data),
|
||||
hostInfo: (configId: number) =>
|
||||
api.get(`/servers/${configId}/instance/host`).then((r) => r.data),
|
||||
version: (configId: number) =>
|
||||
api.get(`/servers/${configId}/instance/version`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tiny helper: one placeholder row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function P({ code, desc, example }: { code: string; desc: string; example?: string }) {
|
||||
return (
|
||||
<div className="py-1.5 grid grid-cols-[1fr_1.4fr_1.6fr] gap-2 items-start text-xs border-b border-border/40 last:border-0">
|
||||
<code className="text-[11px] font-mono text-emerald-400 break-all">{code}</code>
|
||||
<span className="text-muted-foreground">{desc}</span>
|
||||
{example ? (
|
||||
<code className="text-[10px] font-mono text-muted-foreground/70 bg-muted/40 rounded px-1.5 py-0.5 break-all">{example}</code>
|
||||
) : <span />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mt-3 mb-1.5 first:mt-0"><Badge variant="outline" className="text-[10px]">{children}</Badge></div>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export function PlaceholderReference({ open, onOpenChange }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl !grid-rows-none !block p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-2">
|
||||
<DialogTitle className="text-base">Placeholder Reference</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use <code className="text-emerald-400">{'{{placeholder}}'}</code> in any text field. Values are resolved at runtime.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="event" className="px-5 pb-5">
|
||||
<TabsList className="h-8 mb-3">
|
||||
<TabsTrigger value="event" className="text-xs px-2.5 h-6">Event</TabsTrigger>
|
||||
<TabsTrigger value="time" className="text-xs px-2.5 h-6">Time</TabsTrigger>
|
||||
<TabsTrigger value="var" className="text-xs px-2.5 h-6">Variables</TabsTrigger>
|
||||
<TabsTrigger value="temp" className="text-xs px-2.5 h-6">Temp</TabsTrigger>
|
||||
<TabsTrigger value="exec" className="text-xs px-2.5 h-6">Exec</TabsTrigger>
|
||||
<TabsTrigger value="filter" className="text-xs px-2.5 h-6">Filter</TabsTrigger>
|
||||
<TabsTrigger value="functions" className="text-xs px-2.5 h-6">Functions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="h-[58vh]">
|
||||
{/* ========== EVENT ========== */}
|
||||
<TabsContent value="event" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Fields from the triggering TS3 event. Available fields depend on the event type.
|
||||
</p>
|
||||
|
||||
<SectionHeader>Client Connected — notifycliententerview</SectionHeader>
|
||||
<P code="{{event.clid}}" desc="Client ID" example='Move client #{{event.clid}} to channel' />
|
||||
<P code="{{event.client_nickname}}" desc="Nickname" example='Welcome {{event.client_nickname}}!' />
|
||||
<P code="{{event.client_database_id}}" desc="Database ID" example='DB-ID: {{event.client_database_id}}' />
|
||||
<P code="{{event.client_unique_identifier}}" desc="Unique ID (UID)" example='UID: {{event.client_unique_identifier}}' />
|
||||
<P code="{{event.client_type}}" desc="Type (0=User, 1=Query)" example='Condition: event.client_type == 0' />
|
||||
<P code="{{event.client_servergroups}}" desc="Server group IDs (comma-separated)" example="contains(event.client_servergroups, '6')" />
|
||||
<P code="{{event.connection_client_ip}}" desc="IP address" example='VPN check: https://api.vpn.io/{{event.connection_client_ip}}' />
|
||||
<P code="{{event.cid}}" desc="Channel the client joined" example='Condition: event.cid == 42' />
|
||||
|
||||
<SectionHeader>Client Disconnected — notifyclientleftview</SectionHeader>
|
||||
<P code="{{event.clid}}" desc="Client ID" />
|
||||
<P code="{{event.cfid}}" desc="Channel the client was in" />
|
||||
<P code="{{event.reasonid}}" desc="Reason (3=lost, 5=kick, 6=ban, 8=leave)" example='Condition: event.reasonid == 5' />
|
||||
<P code="{{event.reasonmsg}}" desc="Reason message" example='Kicked: {{event.reasonmsg}}' />
|
||||
|
||||
<SectionHeader>Client Moved — notifyclientmoved</SectionHeader>
|
||||
<P code="{{event.clid}}" desc="Client ID" />
|
||||
<P code="{{event.ctid}}" desc="Target channel ID" example='Condition: event.ctid == 10' />
|
||||
<P code="{{event.cfid}}" desc="Source channel ID" />
|
||||
<P code="{{event.reasonid}}" desc="Reason (0=self, 1=moved by admin)" />
|
||||
|
||||
<SectionHeader>Text Message — notifytextmessage</SectionHeader>
|
||||
<P code="{{event.clid}}" desc="Sender client ID" />
|
||||
<P code="{{event.client_nickname}}" desc="Sender nickname" />
|
||||
<P code="{{event.msg}}" desc="Message text" example='User wrote: {{event.msg}}' />
|
||||
<P code="{{event.targetmode}}" desc="Target (1=private, 2=channel, 3=server)" />
|
||||
|
||||
<SectionHeader>Chat Command — trigger_command</SectionHeader>
|
||||
<P code="{{event.command_name}}" desc="Command name (without prefix)" example='Command: {{event.command_name}}' />
|
||||
<P code="{{event.command_args}}" desc="Arguments after command" example='Args: {{event.command_args}}' />
|
||||
<P code="{{event.clid}}" desc="Sender client ID" />
|
||||
<P code="{{event.client_nickname}}" desc="Sender nickname" />
|
||||
|
||||
<SectionHeader>Channel Events — created / edited / deleted</SectionHeader>
|
||||
<P code="{{event.cid}}" desc="Channel ID" />
|
||||
<P code="{{event.invokerid}}" desc="Client ID of who made the change" />
|
||||
<P code="{{event.invokername}}" desc="Name of who made the change" />
|
||||
|
||||
<SectionHeader>Webhook — trigger_webhook</SectionHeader>
|
||||
<P code="{{event.webhook_path}}" desc="Request path" example='/my-hook' />
|
||||
<P code="{{event.webhook_method}}" desc="HTTP method" example='GET or POST' />
|
||||
<P code="{{event.webhook_body}}" desc="Request body (raw JSON)" />
|
||||
<P code="{{event.webhook_body.field}}" desc="Nested field from JSON body" example='Status: {{event.webhook_body.status}}' />
|
||||
<P code="{{event.webhook_query}}" desc="Query parameters (raw JSON)" />
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== TIME ========== */}
|
||||
<TabsContent value="time" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Current date and time. Respects the timezone configured on the Cron trigger (falls back to UTC).
|
||||
</p>
|
||||
<P code="{{time.time}}" desc="Formatted time HH:MM" example='Channel name: Es ist {{time.time}} Uhr' />
|
||||
<P code="{{time.date}}" desc="Formatted date DD.MM.YYYY" example='Datum: {{time.date}}' />
|
||||
<P code="{{time.hours}}" desc="Hour (00-23)" example='Condition: time.hours >= 22' />
|
||||
<P code="{{time.minutes}}" desc="Minute (00-59)" />
|
||||
<P code="{{time.seconds}}" desc="Second (00-59)" />
|
||||
<P code="{{time.day}}" desc="Day of month (01-31)" />
|
||||
<P code="{{time.month}}" desc="Month (01-12)" example='Condition: time.month == 12' />
|
||||
<P code="{{time.year}}" desc="Year (4-digit)" example='2026' />
|
||||
<P code="{{time.dayOfWeek}}" desc="Day of week (0=Sun, 1=Mon, ..., 6=Sat)" example='Condition: time.dayOfWeek == 0' />
|
||||
<P code="{{time.timestamp}}" desc="Unix timestamp (seconds)" example='1739462400' />
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== VARIABLES ========== */}
|
||||
<TabsContent value="var" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Persistent variables stored in the database. Survive restarts. Scoped per flow.
|
||||
Set via the <strong>Set Variable</strong> action node.
|
||||
</p>
|
||||
<P code={'{{var.name}}'} desc="Read variable by name" example='Counter: {{var.visit_count}}' />
|
||||
|
||||
<SectionHeader>Actions for Variables</SectionHeader>
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
<p><strong>Set</strong> — Set a variable to a fixed value or template</p>
|
||||
<p><strong>Increment</strong> — Add a number to the current value</p>
|
||||
<p><strong>Append</strong> — Append text to the current value</p>
|
||||
</div>
|
||||
|
||||
<SectionHeader>Use Case Examples</SectionHeader>
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
<p>Visit counter: Increment <code className="text-emerald-400">visit_count</code> by 1 on each join</p>
|
||||
<p>Last seen: Set <code className="text-emerald-400">{'lastseen_{{event.client_database_id}}'}</code> to <code className="text-emerald-400">{'{{time.timestamp}}'}</code></p>
|
||||
<p>Online time tracking: Increment <code className="text-emerald-400">{'onlinetime_{{event.clid}}'}</code></p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== TEMP ========== */}
|
||||
<TabsContent value="temp" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Temporary variables that only exist during a single flow execution.
|
||||
Automatically set by certain action nodes. Supports nested access via dot notation.
|
||||
</p>
|
||||
|
||||
<SectionHeader>Auto-set by Actions</SectionHeader>
|
||||
<P code="{{temp.lastCreatedChannelId}}" desc="CID of channel created by Create Channel" example='Move client to {{temp.lastCreatedChannelId}}' />
|
||||
<P code="{{temp.lastResult}}" desc="Raw JSON result from WebQuery action" />
|
||||
<P code="{{temp.afkMovedCount}}" desc="Number of clients moved by AFK Mover" example='Moved {{temp.afkMovedCount}} AFK users' />
|
||||
<P code="{{temp.idleKickedCount}}" desc="Number of clients kicked by Idle Kicker" />
|
||||
<P code="{{temp.pokedCount}}" desc="Number of clients poked by Poke Group" />
|
||||
<P code="{{temp.rankPromotedCount}}" desc="Number of clients promoted by Rank Check" />
|
||||
<P code="{{temp.tempChannelsDeleted}}" desc="Number of channels deleted by Temp Cleanup" />
|
||||
|
||||
<SectionHeader>Custom via "Store As"</SectionHeader>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
WebQuery and HTTP Request actions have a "Store As" field. The result is saved as a temp variable.
|
||||
</p>
|
||||
<P code={'{{temp.server.virtualserver_clientsonline}}'} desc='Online users (after serverinfo stored as "server")' example='Online: {{temp.server.virtualserver_clientsonline}}/{{temp.server.virtualserver_maxclients}}' />
|
||||
<P code={'{{temp.server.virtualserver_uptime}}'} desc='Server uptime in seconds' example='Uptime: {{temp.server.virtualserver_uptime|uptime}}' />
|
||||
<P code={'{{temp.client.client_nickname}}'} desc='Client name (after clientinfo stored as "client")' />
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== EXEC ========== */}
|
||||
<TabsContent value="exec" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Read-only metadata about the current flow execution.
|
||||
</p>
|
||||
<P code="{{exec.flowId}}" desc="ID of the flow being executed" />
|
||||
<P code="{{exec.executionId}}" desc="Unique execution instance ID" />
|
||||
<P code="{{exec.configId}}" desc="Server config ID" />
|
||||
<P code="{{exec.sid}}" desc="Virtual server ID" />
|
||||
<P code="{{exec.triggerType}}" desc="Trigger type (event, cron, webhook, command)" example='Condition: exec.triggerType == "cron"' />
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== FILTER ========== */}
|
||||
<TabsContent value="filter" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Filters transform values. Apply with pipe syntax: <code className="text-emerald-400">{'{{value|filter}}'}</code>
|
||||
</p>
|
||||
<P code="uptime" desc="Converts seconds to readable uptime" example='{{temp.server.virtualserver_uptime|uptime}} → "5d 3h 42m"' />
|
||||
<P code="round" desc="Rounds to nearest integer" example='{{temp.value|round}} → "42"' />
|
||||
<P code="floor" desc="Rounds down to integer" example='{{temp.value|floor}} → "41"' />
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== FUNCTIONS ========== */}
|
||||
<TabsContent value="functions" className="mt-0 pr-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Functions for use in <strong>Condition</strong> node expressions. Return 0 or 1 (false/true).
|
||||
</p>
|
||||
<P code="contains(str, sub)" desc="String contains substring?" example="contains(event.client_servergroups, '6')" />
|
||||
<P code="startsWith(str, prefix)" desc="String starts with prefix?" example="startsWith(event.client_nickname, 'Admin')" />
|
||||
<P code="endsWith(str, suffix)" desc="String ends with suffix?" example="endsWith(event.client_nickname, 'Bot')" />
|
||||
<P code="lower(str)" desc="Convert to lowercase" example="contains(lower(event.client_nickname), 'bot')" />
|
||||
<P code="upper(str)" desc="Convert to uppercase" />
|
||||
<P code="length(str)" desc="String length" example="length(event.msg) > 100" />
|
||||
<P code="split(str, sep, idx)" desc="Split string and get element at index" example="split(event.command_args, ' ', 0)" />
|
||||
|
||||
<SectionHeader>Condition Examples</SectionHeader>
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
<p><code className="text-emerald-400">event.client_type == 0</code> — Only real users (no query clients)</p>
|
||||
<p><code className="text-emerald-400">{"contains(event.client_servergroups, '7')"}</code> — Client is in server group 7</p>
|
||||
<p><code className="text-emerald-400">time.hours {'>='} 22 or time.hours {'<'} 6</code> — Nighttime only</p>
|
||||
<p><code className="text-emerald-400">event.ctid == 42</code> — Client moved to specific channel</p>
|
||||
<p><code className="text-emerald-400">temp.vpn.security.vpn == 1</code> — VPN detected (after HTTP request)</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { BOT_TEMPLATES, TEMPLATE_CATEGORIES, type BotTemplate } from '@/data/bot-templates';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TemplateGalleryProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (name: string, description: string, flowData: { nodes: any[]; edges: any[] }) => void;
|
||||
}
|
||||
|
||||
export function TemplateGallery({ open, onOpenChange, onSelect }: TemplateGalleryProps) {
|
||||
const [selected, setSelected] = useState<BotTemplate | null>(null);
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
|
||||
const handleBack = () => {
|
||||
setSelected(null);
|
||||
setConfig({});
|
||||
};
|
||||
|
||||
const handleClose = (v: boolean) => {
|
||||
if (!v) {
|
||||
setSelected(null);
|
||||
setConfig({});
|
||||
}
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (template: BotTemplate) => {
|
||||
setSelected(template);
|
||||
// Initialize default values
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const field of template.configFields) {
|
||||
if (field.defaultValue) defaults[field.key] = field.defaultValue;
|
||||
}
|
||||
setConfig(defaults);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!selected) return;
|
||||
const flowData = selected.flowDataFactory(config);
|
||||
onSelect(selected.name, selected.description, flowData);
|
||||
handleClose(false);
|
||||
};
|
||||
|
||||
const isValid = selected
|
||||
? selected.configFields.filter(f => f.required).every(f => config[f.key]?.trim())
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl !grid-rows-none !block p-0 overflow-hidden">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selected && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 mr-1" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{selected ? `Configure: ${selected.name}` : 'Flow Templates'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
{!selected ? (
|
||||
<div className="overflow-y-auto max-h-[60vh] px-6">
|
||||
<div className="space-y-6 pb-4">
|
||||
{TEMPLATE_CATEGORIES.map((cat) => {
|
||||
const templates = BOT_TEMPLATES.filter(t => t.category === cat.id);
|
||||
if (templates.length === 0) return null;
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div className="mb-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60">{cat.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{cat.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map((tpl) => {
|
||||
const Icon = tpl.icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border bg-card/50 p-3 text-left transition-colors',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
)}
|
||||
onClick={() => handleSelectTemplate(tpl)}
|
||||
>
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">{tpl.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2 mt-0.5">{tpl.description}</p>
|
||||
{tpl.configFields.length > 0 && (
|
||||
<div className="flex gap-1 mt-1.5 flex-wrap">
|
||||
{tpl.configFields.filter(f => f.required).map(f => (
|
||||
<Badge key={f.key} variant="secondary" className="text-[8px] px-1 py-0">{f.label}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-y-auto max-h-[60vh] px-6">
|
||||
<div className="space-y-4 pb-2">
|
||||
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||
|
||||
{selected.configFields.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/60">No configuration needed — ready to create.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selected.configFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{field.label} {field.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
value={config[field.key] || field.defaultValue || ''}
|
||||
onValueChange={(v) => setConfig(prev => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
className="h-7 text-xs mt-1 font-mono-data"
|
||||
placeholder={field.placeholder}
|
||||
value={config[field.key] || ''}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-2">
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>Back</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={selected.configFields.length > 0 && !isValid}>
|
||||
Create Bot
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
export function AppLayout() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated());
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto grid-bg">
|
||||
<div className="p-5 fade-in">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
className: 'bg-popover text-popover-foreground border-border',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { LogOut, User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { ThemeToggle } from '@/components/shared/ThemeToggle';
|
||||
import { ServerSelector } from './ServerSelector';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useLogout } from '@/hooks/use-auth';
|
||||
|
||||
export function Header() {
|
||||
const { user } = useAuthStore();
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-14 px-5 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<ServerSelector />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2 h-8">
|
||||
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<User className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs">{user?.displayName}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-sm font-medium">{user?.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{user?.role}</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers, useVirtualServers } from '@/hooks/use-servers';
|
||||
import { useServerStore } from '@/stores/server.store';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
export function ServerSelector() {
|
||||
const { selectedConfigId, selectedSid, setServer, setSid } = useServerStore();
|
||||
const { data: servers } = useServers();
|
||||
const { data: virtualServers } = useVirtualServers();
|
||||
|
||||
// Auto-select first server if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedConfigId && servers?.length > 0) {
|
||||
setServer(servers[0].id);
|
||||
}
|
||||
}, [servers, selectedConfigId, setServer]);
|
||||
|
||||
// Auto-select first virtual server
|
||||
useEffect(() => {
|
||||
if (selectedConfigId && !selectedSid && virtualServers?.length > 0) {
|
||||
setSid(virtualServers[0].virtualserver_id);
|
||||
}
|
||||
}, [virtualServers, selectedConfigId, selectedSid, setSid]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={selectedConfigId?.toString() || ''}
|
||||
onValueChange={(v) => setServer(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select server..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers?.map((s: any) => (
|
||||
<SelectItem key={s.id} value={s.id.toString()}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{virtualServers && virtualServers.length > 1 && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<Select
|
||||
value={selectedSid?.toString() || ''}
|
||||
onValueChange={(v) => setSid(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[160px] h-8 text-xs">
|
||||
<SelectValue placeholder="Virtual server..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{virtualServers.map((vs: any) => (
|
||||
<SelectItem key={vs.virtualserver_id} value={vs.virtualserver_id.toString()}>
|
||||
{vs.virtualserver_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Server, Hash, Users, Shield, ShieldCheck,
|
||||
Lock, Ban, KeyRound, FolderOpen, MessageSquareWarning, Mail,
|
||||
ScrollText, Settings, Bot, Cpu, ChevronLeft, ChevronRight, Music,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/servers', icon: Server, label: 'Virtual Servers', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Management',
|
||||
items: [
|
||||
{ to: '/channels', icon: Hash, label: 'Channels' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/server-groups', icon: Shield, label: 'Server Groups', adminOnly: true },
|
||||
{ to: '/channel-groups', icon: ShieldCheck, label: 'Channel Groups', adminOnly: true },
|
||||
{ to: '/permissions', icon: Lock, label: 'Permissions', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/bans', icon: Ban, label: 'Bans', adminOnly: true },
|
||||
{ to: '/tokens', icon: KeyRound, label: 'Tokens', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/files', icon: FolderOpen, label: 'Files', adminOnly: true },
|
||||
{ to: '/complaints', icon: MessageSquareWarning, label: 'Complaints', adminOnly: true },
|
||||
{ to: '/messages', icon: Mail, label: 'Messages', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/logs', icon: ScrollText, label: 'Server Logs', adminOnly: true },
|
||||
{ to: '/instance', icon: Cpu, label: 'Instance', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Automation',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/bots', icon: Bot, label: 'Bot Flows', adminOnly: true },
|
||||
{ to: '/music-bots', icon: Music, label: 'Music Bots', adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { sidebarCollapsed, toggleSidebar } = useUiStore();
|
||||
const isAdmin = useAuthStore((s) => s.isAdmin());
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-all duration-300 ease-in-out relative',
|
||||
sidebarCollapsed ? 'w-16' : 'w-56',
|
||||
)}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<div className={cn('flex items-center h-14 px-4 border-b border-sidebar-border', sidebarCollapsed && 'justify-center px-0')}>
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-md bg-primary/20 flex items-center justify-center">
|
||||
<span className="text-primary font-bold text-xs font-mono-data">TS</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-sidebar-accent-foreground">TS6</span>
|
||||
<span className="text-sm text-sidebar-foreground ml-1">Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-7 w-7 rounded-md bg-primary/20 flex items-center justify-center">
|
||||
<span className="text-primary font-bold text-xs font-mono-data">TS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="space-y-1 px-2">
|
||||
{navSections
|
||||
.filter((section) => !(section as any).adminOnly || isAdmin)
|
||||
.map((section, si) => {
|
||||
const visibleItems = section.items.filter((item) => !(item as any).adminOnly || isAdmin);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div key={section.label}>
|
||||
{si > 0 && <Separator className="my-2 bg-sidebar-border" />}
|
||||
{!sidebarCollapsed && (
|
||||
<p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
|
||||
{section.label}
|
||||
</p>
|
||||
)}
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = location.pathname === item.to || location.pathname.startsWith(item.to + '/');
|
||||
const link = (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-all duration-150',
|
||||
sidebarCollapsed && 'justify-center px-0 py-2',
|
||||
isActive
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('h-4 w-4 shrink-0', isActive && 'text-primary')} />
|
||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.to}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="font-medium">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return link;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Settings + Collapse */}
|
||||
<div className="border-t border-sidebar-border p-2 space-y-1">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground transition-colors',
|
||||
sidebarCollapsed && 'justify-center px-0 py-2',
|
||||
location.pathname.startsWith('/settings') && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{!sidebarCollapsed && <span>Settings</span>}
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors w-full',
|
||||
sidebarCollapsed && 'justify-center px-0 py-2',
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{!sidebarCollapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open, onOpenChange, title, description,
|
||||
confirmLabel = 'Confirm', destructive, onConfirm, loading,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Processing...' : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronUp, ChevronsUpDown, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns, data, searchKey, searchPlaceholder = 'Search...', pageSize = 20,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
state: { sorting, globalFilter },
|
||||
initialState: { pagination: { pageSize } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{searchKey !== undefined && (
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<tr key={hg.id} className="border-b border-border bg-muted/30">
|
||||
{hg.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'h-10 px-3 text-left align-middle font-medium text-muted-foreground',
|
||||
header.column.getCanSort() && 'cursor-pointer select-none hover:text-foreground transition-colors',
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<span className="ml-1">
|
||||
{header.column.getIsSorted() === 'asc' ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : header.column.getIsSorted() === 'desc' ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2.5 align-middle">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||
No results.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length} result(s)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground font-mono-data">
|
||||
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type LucideIcon, Inbox } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon = Inbox, title, description, className, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
|
||||
<div className="rounded-xl border border-dashed border-border p-4 mb-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground max-w-sm">{description}</p>}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user