Initial commit

This commit is contained in:
Clusterzx
2026-02-24 22:33:33 +01:00
commit a8ad91fd18
163 changed files with 26110 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
**/node_modules/
**/dist/
**/generated/
**/.env
**/*.db
.git/
.claude/
.vscode/
+12
View File
@@ -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
View File
@@ -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/
+47
View File
@@ -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"]
+54
View File
@@ -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
+182
View File
@@ -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.
![License](https://img.shields.io/badge/license-MIT-blue)
## 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
+42
View File
@@ -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
+48
View File
@@ -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
+22
View File
@@ -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"
}
}
+52
View File
@@ -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"
}
}
+198
View File
@@ -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())
}
+32
View File
@@ -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());
+80
View File
@@ -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;
}
}
+274
View File
@@ -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 };
}
}
+687
View File
@@ -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 = [];
}
}
+16
View File
@@ -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',
};
+71
View File
@@ -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);
});
+29
View File
@@ -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' });
}
+16
View File
@@ -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();
};
}
+131
View File
@@ -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); }
});
+150
View File
@@ -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}`));
});
});
}
}
+220
View File
@@ -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}`));
});
});
}
+8
View File
@@ -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]];
}
}
}
+966
View File
@@ -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 };
}
+185
View File
@@ -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"));
}
+11
View File
@@ -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';
+168
View File
@@ -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 };
}
+124
View File
@@ -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);
}
});
}
}
+523
View File
@@ -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);
}
}
+13
View File
@@ -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/**/*"]
}
+22
View File
@@ -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"
}
}
+35
View File
@@ -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;
+7
View File
@@ -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';
+202
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
export type UserRole = 'admin' | 'viewer';
export interface JwtPayload {
id: number;
username: string;
role: UserRole;
iat?: number;
exp?: number;
}
+294
View File
@@ -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;
}
+127
View File
@@ -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[];
}
+363
View File
@@ -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',
};
+48
View File
@@ -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;
});
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
+16
View File
@@ -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"
}
}
+21
View File
@@ -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>
+61
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+81
View File
@@ -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>
);
}
+17
View File
@@ -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 }),
};
+61
View File
@@ -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),
};
+21
View File
@@ -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}`),
};
+21
View File
@@ -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),
};
+42
View File
@@ -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;
+23
View File
@@ -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),
};
+13
View File
@@ -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),
};
+36
View File
@@ -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),
};
+82
View File
@@ -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),
};
+38
View File
@@ -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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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> &mdash; Set a variable to a fixed value or template</p>
<p><strong>Increment</strong> &mdash; Add a number to the current value</p>
<p><strong>Append</strong> &mdash; 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> &mdash; Only real users (no query clients)</p>
<p><code className="text-emerald-400">{"contains(event.client_servergroups, '7')"}</code> &mdash; Client is in server group 7</p>
<p><code className="text-emerald-400">time.hours {'>='} 22 or time.hours {'<'} 6</code> &mdash; Nighttime only</p>
<p><code className="text-emerald-400">event.ctid == 42</code> &mdash; Client moved to specific channel</p>
<p><code className="text-emerald-400">temp.vpn.security.vpn == 1</code> &mdash; 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