diff --git a/.env.example b/.env.example index 4cce0cb..3fd98ab 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Holo Bridge - Environment Variables +# HoloBridge - Environment Configuration # Discord Bot Token (required) # Get this from https://discord.com/developers/applications @@ -8,5 +8,18 @@ DISCORD_TOKEN=your_discord_bot_token_here PORT=3000 API_KEY=your_secure_api_key_here -# Optional: Enable debug logging +# Optional: Multiple API keys with scopes (JSON array) +# API_KEYS=[{"id":"key1","name":"Read Only","key":"readonly_key","scopes":["read:guilds","read:messages"]}] + +# Plugin System +PLUGINS_ENABLED=true +PLUGINS_DIR=plugins + +# Rate Limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 + +# Debug Mode DEBUG=false + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ceea4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Stage 1: Build +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install all dependencies (including dev for building) +RUN npm ci + +# Copy source code +COPY . . + +# Build TypeScript +RUN npm run build + +# Stage 2: Production +FROM node:18-alpine + +WORKDIR /app + +# Copy built files from builder +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package*.json ./ + +# Install production dependencies only +RUN npm ci --production + +# Create plugins directory +RUN mkdir -p plugins + +# Install wget for healthcheck +RUN apk add --no-cache wget + +# Expose API port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +# Run the application +CMD ["npm", "start"] diff --git a/README.md b/README.md index 3b20216..473ba2d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,127 @@ -# Holo Bridge +# HoloBridge -A type-safe TypeScript bridge between websites and Discord bots. Provides a REST API and WebSocket interface for full Discord bot capabilities. +A type-safe TypeScript bridge between websites and Discord bots. Provides a REST API, WebSocket interface, and plugin system for full Discord bot capabilities. ## Features - **REST API** for all Discord operations - **WebSocket** real-time event streaming +- **Plugin System** for extensibility +- **Granular API Scopes** for secure access control +- **Rate Limiting** for API protection +- **CLI Tool** for easy management +- **Docker Ready** for easy deployment - **Type-safe** with Zod validation -- **Full Discord.js coverage**: - - Guilds, Channels, Roles - - Members, Bans, Timeouts - - Messages, Reactions, Pins - - Voice state tracking - - And more... ## Quick Start -### 1. Install Dependencies +### Option 1: Using the CLI ```bash +# Install globally +npm install -g holobridge + +# Initialize configuration +holo init + +# Check your setup +holo doctor + +# Start the server +holo start +``` + +### Option 2: Manual Setup + +```bash +# Install dependencies npm install + +# Copy environment file +cp .env.example .env +# Edit .env with your Discord token and API key + +# Build and run +npm run build +npm start +``` + +### Option 3: Docker + +```bash +# Using Docker Compose +docker-compose up -d + +# Or build manually +docker build -t holobridge . +docker run -p 3000:3000 --env-file .env holobridge ``` -### 2. Configure Environment +## Configuration -Copy `.env.example` to `.env` and fill in your values: +Copy `.env.example` to `.env` and configure: ```env +# Required DISCORD_TOKEN=your_discord_bot_token API_KEY=your_secure_api_key + +# Optional PORT=3000 +DEBUG=false +PLUGINS_ENABLED=true +RATE_LIMIT_ENABLED=true +RATE_LIMIT_MAX=100 ``` -### 3. Run +### Multiple API Keys with Scopes -```bash -# Development -npm run dev +For granular access control, use the `API_KEYS` environment variable: -# Production -npm run build -npm start +```env +API_KEYS=[{"id":"readonly","name":"Dashboard","key":"dash_xxx","scopes":["read:guilds","read:messages"]}] +``` + +**Available Scopes:** +- `read:guilds`, `read:channels`, `read:members`, `read:messages` +- `write:messages`, `write:members`, `write:channels`, `write:roles` +- `events` (WebSocket access) +- `admin` (full access) + +## Plugin System + +Extend HoloBridge with custom plugins. Create `.js` files in the `plugins/` directory: + +```javascript +export default { + metadata: { + name: 'my-plugin', + version: '1.0.0', + }, + + onLoad(ctx) { + ctx.log('Plugin loaded!'); + // ctx.client - Discord.js Client + // ctx.io - Socket.IO Server + }, + + onEvent(eventName, data) { + if (eventName === 'messageCreate') { + // React to messages + } + }, +}; +``` + +See [plugins/README.md](plugins/README.md) for full documentation. + +## CLI Commands + +```bash +holo start # Start the server +holo start --watch # Development mode with hot reload +holo doctor # Check configuration and environment +holo init # Interactive setup wizard ``` ## API Reference @@ -53,6 +134,13 @@ All API requests require the `X-API-Key` header: curl -H "X-API-Key: your_key" http://localhost:3000/api/guilds ``` +### Rate Limiting + +Responses include rate limit headers: +- `X-RateLimit-Limit` - Max requests per window +- `X-RateLimit-Remaining` - Requests remaining +- `X-RateLimit-Reset` - Unix timestamp when limit resets + ### REST Endpoints | Method | Endpoint | Description | @@ -68,6 +156,7 @@ curl -H "X-API-Key: your_key" http://localhost:3000/api/guilds | `POST` | `/api/channels/:id/messages` | Send message | | `PATCH` | `/api/channels/:id/messages/:msgId` | Edit message | | `DELETE` | `/api/channels/:id/messages/:msgId` | Delete message | +| `GET` | `/health` | Health check (no auth) | ### WebSocket Events @@ -80,33 +169,48 @@ const socket = io('http://localhost:3000', { auth: { apiKey: 'your_key' } }); -// Subscribe to guild events socket.emit('subscribe', { guildIds: ['123456789'] }); -// Listen for Discord events socket.on('discord', (event) => { console.log(event.event, event.data); }); ``` -**Events:** `messageCreate`, `messageUpdate`, `messageDelete`, `guildMemberAdd`, `guildMemberRemove`, `voiceStateUpdate`, and more. +**Supported Events (45+):** Messages, Reactions, Members, Channels, Threads, Roles, Guilds, Emojis, Voice, Scheduled Events, AutoMod, Invites, Interactions, and more. ## Discord Bot Setup 1. Go to [Discord Developer Portal](https://discord.com/developers/applications) -2. Create a new application -3. Go to Bot section and create a bot -4. Enable these **Privileged Gateway Intents**: +2. Create a new application and bot +3. Enable **ALL Privileged Gateway Intents**: - Presence Intent - Server Members Intent - Message Content Intent -5. Copy the token to your `.env` file -6. Invite the bot to your server with appropriate permissions +4. Copy the token to your `.env` file +5. Invite the bot with `Administrator` permissions -## Documentation +## Project Structure -Visit the [documentation](https://holodocs.pages.dev/) for more details. +``` +holobridge/ +├── bin/holo.js # CLI tool +├── plugins/ # Plugin directory +├── src/ +│ ├── api/ # REST API routes & middleware +│ ├── discord/ # Discord client & events +│ ├── plugins/ # Plugin manager +│ └── types/ # TypeScript types +├── Dockerfile # Docker build +└── docker-compose.yml # Docker Compose config +``` + +## Resources + +- [Use Cases](USE_CASES.md) - Creative ways to use HoloBridge +- [Plugin Guide](plugins/README.md) - How to build plugins +- [Documentation](https://holodocs.pages.dev/) - Full API docs ## License MIT + diff --git a/USE_CASES.md b/USE_CASES.md new file mode 100644 index 0000000..88c9be2 --- /dev/null +++ b/USE_CASES.md @@ -0,0 +1,106 @@ +# 🌉 HoloBridge Use Cases + +HoloBridge decouples your Discord bot's logic from its connection. By exposing a **REST API** for actions and a **WebSocket** for events, it opens up a world of possibilities that standard Discord libraries can't easily handle. + +Here is a collection of use cases ranging from practical enterprise solutions to fun weekend experiments. + +--- + +## 🏢 The "Standard" Integrations +*Practical solutions for common development problems.* + +### 1. The Web Dashboard +**Scenario:** You want a Next.js or React dashboard where admins can manage their server. +* **The Problem:** Connecting a web frontend directly to Discord is insecure (exposing tokens) or requires a complex backend just to proxy requests. +* **The Fix:** Your frontend calls HoloBridge's REST API to fetch roles, kick members, or update settings. +* **Flow:** `Admin clicks "Ban" on Website` → `POST /api/guilds/:id/members/:id/ban` → `HoloBridge` → `Discord`. + +### 2. Serverless Bot Logic +**Scenario:** You have a bot that is rarely used and you don't want to pay for 24/7 hosting. +* **The Fix:** Host HoloBridge on a tiny, cheap VPS (or free tier). Point it to a serverless function (AWS Lambda, Vercel Functions). +* **Flow:** `User types !help` → `HoloBridge WebSocket` → `Your Listener Script` → `Trigger Serverless Function` → `REST API Reply`. + +### 3. Unified Community Auth +**Scenario:** Syncing website subscriptions with Discord roles. +* **The Fix:** When a user subscribes on your site (Stripe webhook), your backend immediately hits HoloBridge to assign the "Premium" role. +* **Benefit:** Instant role assignment without waiting for a bot polling interval. + +--- + +## 🧠 Advanced Architectures +*For power users building complex systems.* + +### 4. The "External Brain" AI Agent +**Scenario:** Running a massive LLM (Llama 3, Grok) that requires a GPU server. +* **The Setup:** + * **Server A (Cheap VPS):** Runs HoloBridge. Handles the persistent Discord connection. + * **Server B (GPU Monster):** Runs the AI model. +* **The Flow:** HoloBridge streams `messageCreate` events to Server B. Server B processes the text and replies via HoloBridge's REST API. +* **Why?** If the AI server crashes or restarts, your bot stays "online" in Discord. You can also swap AI models without restarting the bot connection. + +### 5. Multi-Platform Chat Bridge +**Scenario:** Linking a Discord channel with Slack, Matrix, or IRC. +* **The Fix:** A lightweight "translator" script connects to HoloBridge and the other platform's API. +* **Flow:** `Discord Message` → `HoloBridge WS` → `Translator Script` → `Slack API`. +* **Why?** HoloBridge handles the complex Gateway intent management (reconnects, heartbeats), so your translator script remains simple. + +### 6. Compliance "Black Box" +**Scenario:** An enterprise needs to archive *everything* for legal compliance. +* **The Fix:** A secure, read-only logging service connects to HoloBridge's WebSocket. It silently records every `messageCreate`, `messageDelete`, and `guildAuditLogEntryCreate` into a cold-storage database (S3, ClickHouse). +* **Security:** The logger has no write access to Discord; it simply consumes the stream. + +--- + +## 🎮 Real-World "Clever" Cases +*Integration with external software and hardware.* + +### 7. Real-Time Game Server Sync +**Scenario:** A Minecraft or Rust server that syncs chat and bans. +* **The Problem:** Implementing a full Discord library in Java/C++/Rust can be heavy or conflict with game threads. +* **The Fix:** The game server uses a lightweight HTTP/WebSocket client to talk to HoloBridge. +* **Flow:** `Player dies in-game` → `Game Server` → `POST /api/channels/:id/messages` → `Discord: "Steve fell from a high place"`. + +### 8. Dynamic OBS Stream Overlay +**Scenario:** A Twitch streamer wants an overlay that reacts to Discord voice activity. +* **The Fix:** A simple HTML/JS browser source in OBS connects to HoloBridge. +* **Flow:** `User joins Voice Channel` → `voiceStateUpdate Event` → `OBS Overlay plays entrance theme song`. +* **Creativity:** Show a "Talking Now" visualizer that bounces to the user's avatar when they speak. + +--- + +## 🧪 Fun & Creative Experiments +*Weekend projects to impress your friends.* + +### 9. The "Office DJ" +**Scenario:** A shared Spotify playlist for the office. +* **The Setup:** A bot in the office voice channel. +* **The Flow:** Users join a specific "Request" voice channel to queue a song, or use reaction emojis on a "Now Playing" message to skip tracks. HoloBridge events trigger a script controlling the office speakers. + +### 10. Smart Home "God Mode" +**Scenario:** Controlling your house via Discord. +* **The Setup:** Home Assistant + HoloBridge. +* **The Flow:** + * `!lights on` → HoloBridge → Home Assistant turns on lights. + * **Doorbell Rings** → Home Assistant → HoloBridge → Bot posts a photo of the visitor in `#security-logs`. + +### 11. The Stock/Crypto Ticker +**Scenario:** A channel name that updates with the price of Bitcoin. +* **The Fix:** A script polls an API for prices and uses HoloBridge's `PATCH /api/channels/:id` to update the channel name every 10 minutes. +* **Result:** `#btc-98k` (Updates automatically). + +### 12. "Dad Bot" Microservice +**Scenario:** A dedicated service just for dad jokes. +* **The Fix:** A tiny script that listens *only* for messages starting with "I'm". +* **Flow:** `User: "I'm hungry"` → `Script: "Hi hungry, I'm Dad"` via HoloBridge. +* **Why?** Because you can run this annoying logic separately from your serious moderation bot. + +--- + +## 🚀 Why Use HoloBridge? + +| Feature | Standard Bot | HoloBridge | +| :--- | :--- | :--- | +| **Language** | Node.js/Python/etc. | **Any** (via HTTP/WS) | +| **Hosting** | Stateful Process | **Decoupled** | +| **Scaling** | Sharding is hard | **Stateless API** | +| **Complexity** | High (Intents, Cache) | **Low** (Simple JSON) | diff --git a/bin/holo.js b/bin/holo.js new file mode 100644 index 0000000..5562b4c --- /dev/null +++ b/bin/holo.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +/** + * HoloBridge CLI + * + * Usage: + * holo start - Start the HoloBridge server + * holo doctor - Check environment and configuration + * holo init - Initialize a new configuration + */ + +import { spawn } from 'child_process'; +import { randomBytes } from 'crypto'; +import { readFile, writeFile, access, mkdir } from 'fs/promises'; +import { constants } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as readline from 'readline'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = resolve(__dirname, '..'); + +// ANSI colors +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + bold: '\x1b[1m', +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function checkmark() { return `${colors.green}✓${colors.reset}`; } +function crossmark() { return `${colors.red}✗${colors.reset}`; } +function warnmark() { return `${colors.yellow}⚠${colors.reset}`; } + +// ============ Commands ============ + +async function commandStart(args) { + const isWatch = args.includes('--watch') || args.includes('-w'); + + log('\n🚀 Starting HoloBridge...', 'cyan'); + + const command = isWatch ? 'npm' : 'node'; + const cmdArgs = isWatch ? ['run', 'dev'] : ['dist/index.js']; + + const child = spawn(command, cmdArgs, { + cwd: ROOT_DIR, + stdio: 'inherit', + shell: true, + }); + + child.on('error', (err) => { + log(`\nError: ${err.message}`, 'red'); + process.exit(1); + }); + + child.on('exit', (code) => { + process.exit(code ?? 0); + }); +} + +async function commandDoctor() { + log('\n🩺 HoloBridge Doctor\n', 'cyan'); + let hasErrors = false; + + // Check Node.js version + const nodeVersion = process.version; + const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0], 10); + if (majorVersion >= 18) { + log(`${checkmark()} Node.js ${nodeVersion} (>= 18 required)`); + } else { + log(`${crossmark()} Node.js ${nodeVersion} (>= 18 required)`, 'red'); + hasErrors = true; + } + + // Check .env file + const envPath = resolve(ROOT_DIR, '.env'); + try { + await access(envPath, constants.R_OK); + log(`${checkmark()} .env file exists`); + + // Check required variables + const envContent = await readFile(envPath, 'utf8'); + const hasToken = /DISCORD_TOKEN=.+/.test(envContent); + const hasApiKey = /API_KEY=.+/.test(envContent); + + if (hasToken) { + log(`${checkmark()} DISCORD_TOKEN is set`); + } else { + log(`${crossmark()} DISCORD_TOKEN is missing or empty`, 'red'); + hasErrors = true; + } + + if (hasApiKey) { + log(`${checkmark()} API_KEY is set`); + } else { + log(`${crossmark()} API_KEY is missing or empty`, 'red'); + hasErrors = true; + } + } catch { + log(`${crossmark()} .env file not found`, 'red'); + log(` Run 'holo init' to create one`, 'yellow'); + hasErrors = true; + } + + // Check plugins directory + const pluginsPath = resolve(ROOT_DIR, 'plugins'); + try { + await access(pluginsPath, constants.R_OK); + log(`${checkmark()} plugins/ directory exists`); + } catch { + log(`${warnmark()} plugins/ directory not found (will be created on start)`); + } + + // Check dist directory + const distPath = resolve(ROOT_DIR, 'dist'); + try { + await access(distPath, constants.R_OK); + log(`${checkmark()} dist/ directory exists (built)`); + } catch { + log(`${warnmark()} dist/ not found. Run 'npm run build' first`); + } + + console.log(''); + if (hasErrors) { + log('❌ Some checks failed. Please fix the issues above.', 'red'); + process.exit(1); + } else { + log('✨ All checks passed! Ready to start.', 'green'); + } +} + +async function commandInit() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (q) => new Promise((resolve) => rl.question(q, resolve)); + + log('\n🔧 HoloBridge Setup\n', 'cyan'); + + const envPath = resolve(ROOT_DIR, '.env'); + + // Check if .env already exists + try { + await access(envPath, constants.F_OK); + const overwrite = await question('.env already exists. Overwrite? (y/N): '); + if (overwrite.toLowerCase() !== 'y') { + log('Aborted.', 'yellow'); + rl.close(); + return; + } + } catch { + // File doesn't exist, continue + } + + const discordToken = await question('Discord Bot Token: '); + const apiKey = await question('API Key (leave empty to generate): '); + const port = await question('Port (default: 3000): '); + + const finalApiKey = apiKey || generateApiKey(); + const finalPort = port || '3000'; + + const envContent = `# HoloBridge Configuration + +# Discord Bot Token (from Discord Developer Portal) +DISCORD_TOKEN=${discordToken} + +# API Key for REST/WebSocket authentication +API_KEY=${finalApiKey} + +# Server port +PORT=${finalPort} + +# Debug mode (set to true for verbose logging) +DEBUG=false + +# Plugin settings +PLUGINS_ENABLED=true +PLUGINS_DIR=plugins + +# Rate limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 +`; + + await writeFile(envPath, envContent); + + // Create plugins directory + const pluginsPath = resolve(ROOT_DIR, 'plugins'); + try { + await mkdir(pluginsPath, { recursive: true }); + } catch { + // Already exists + } + + log('\n✅ Configuration saved to .env', 'green'); + if (!apiKey) { + log(` Generated API Key: ${finalApiKey}`, 'cyan'); + } + log('\nRun `holo doctor` to verify your setup.', 'yellow'); + + rl.close(); +} + +function generateApiKey() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charsLength = chars.length; + const bytes = randomBytes(32); // 32 bytes of cryptographically secure entropy + let key = 'holo_'; + for (let i = 0; i < 32; i++) { + key += chars.charAt(bytes[i] % charsLength); + } + return key; +} + +function showHelp() { + log('\n📚 HoloBridge CLI', 'cyan'); + console.log(` +Usage: holo [options] + +Commands: + start Start the HoloBridge server + --watch, -w Run in development mode with hot reload + + doctor Check your environment and configuration + + init Initialize a new .env configuration file + + help Show this help message + +Examples: + holo start Start in production mode + holo start --watch Start in development mode + holo doctor Verify your setup + holo init Create a new .env file +`); +} + +// ============ Main ============ + +const args = process.argv.slice(2); +const command = args[0]; + +(async () => { + try { + switch (command) { + case 'start': + await commandStart(args.slice(1)); + break; + case 'doctor': + await commandDoctor(); + break; + case 'init': + await commandInit(); + break; + case 'help': + case '--help': + case '-h': + case undefined: + showHelp(); + break; + default: + log(`Unknown command: ${command}`, 'red'); + showHelp(); + process.exit(1); + } + } catch (err) { + log(`Error: ${err.message}`, 'red'); + process.exit(1); + } +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe6b12a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + holobridge: + build: . + container_name: holobridge + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + environment: + - DISCORD_TOKEN=${DISCORD_TOKEN} + - API_KEY=${API_KEY} + - PORT=3000 + - DEBUG=${DEBUG:-false} + - PLUGINS_ENABLED=${PLUGINS_ENABLED:-true} + - RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-true} + volumes: + # Mount plugins directory for hot-reload + - ./plugins:/app/plugins:ro + # Mount .env file + - ./.env:/app/.env:ro + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # Optional: Redis for scaling (uncomment to enable) + # redis: + # image: redis:7-alpine + # container_name: holobridge-redis + # restart: unless-stopped + # ports: + # - "6379:6379" + # volumes: + # - redis_data:/data + + # volumes: + # redis_data: diff --git a/docs/api-reference.html b/docs/api-reference.html index 6f3ee42..a20c1f9 100644 --- a/docs/api-reference.html +++ b/docs/api-reference.html @@ -18,6 +18,7 @@ Getting Started API Reference WebSocket + Security @@ -71,6 +72,37 @@

Roles

  • Edit Role
  • Delete Role
  • +

    Stickers

    + +

    Scheduled Events

    + +

    AutoMod

    + +

    Other Resources

    +
    @@ -771,6 +803,114 @@

    PATCH Set role permissions. Send permissions (bitfield string) in body.

    + + +
    +

    Stickers

    +
    +

    GET /api/guilds/:guildId/stickers

    +

    List all stickers in a guild.

    +
    +
    +

    GET /api/guilds/:guildId/stickers/:stickerId

    +

    Get a specific sticker.

    +
    +
    +

    POST /api/guilds/:guildId/stickers

    +

    Create a new sticker.

    +
    +
    +

    PATCH /api/guilds/:guildId/stickers/:stickerId

    +

    Edit a sticker.

    +
    +
    +

    DELETE /api/guilds/:guildId/stickers/:stickerId

    +

    Delete a sticker.

    +
    +
    + +
    +

    Scheduled Events

    +
    +

    GET /api/guilds/:guildId/scheduled-events

    +

    List all scheduled events.

    +
    +
    +

    GET /api/guilds/:guildId/scheduled-events/:eventId

    +

    Get a specific event.

    +
    +
    +

    POST /api/guilds/:guildId/scheduled-events

    +

    Create a new event.

    +
    +
    +

    PATCH /api/guilds/:guildId/scheduled-events/:eventId

    +

    Edit an event.

    +
    +
    +

    DELETE /api/guilds/:guildId/scheduled-events/:eventId

    +

    Delete an event.

    +
    +
    + +
    +

    AutoMod

    +
    +

    GET /api/guilds/:guildId/auto-moderation/rules

    +

    List all auto-moderation rules.

    +
    +
    +

    GET /api/guilds/:guildId/auto-moderation/rules/:ruleId

    +

    Get a specific rule.

    +
    +
    +

    POST /api/guilds/:guildId/auto-moderation/rules

    +

    Create a new rule.

    +
    +
    +

    PATCH /api/guilds/:guildId/auto-moderation/rules/:ruleId

    +

    Edit a rule.

    +
    +
    +

    DELETE /api/guilds/:guildId/auto-moderation/rules/:ruleId

    +

    Delete a rule.

    +
    +
    + +
    +

    Other Resources

    +
    +

    Stage Instances

    +

    Endpoints: /api/stage-instances (GET, POST, PATCH, DELETE)

    +
    +
    +

    Invites

    +

    Endpoints: /api/invites/:code (GET, DELETE)

    +
    +
    +

    Webhooks

    +

    Endpoints: /api/webhooks/:webhookId (GET, PATCH, DELETE)

    +
    +
    +

    Emojis

    +

    Endpoints: /api/guilds/:guildId/emojis (GET, POST, PATCH, DELETE)

    +
    +
    diff --git a/docs/getting-started.html b/docs/getting-started.html index dda5e3a..476e0a2 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -18,6 +18,7 @@ Getting Started API Reference WebSocket + Security @@ -37,8 +38,8 @@

    Prerequisites

    Installation

    1. Clone the Repository

    -git clone https://github.com/coder-soft/holobridge.git -cd holobridge + git clone https://github.com/coder-soft/holobridge.git + cd holobridge

    2. Install Dependencies

    npm install
    diff --git a/docs/index.html b/docs/index.html index 9d7f730..da440bc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -19,6 +19,7 @@ Getting Started API Reference WebSocket + Security diff --git a/docs/security.html b/docs/security.html new file mode 100644 index 0000000..dfc2683 --- /dev/null +++ b/docs/security.html @@ -0,0 +1,198 @@ + + + + + + + Security & API Scopes - HoloBridge + + + + + +
    +
    + + +
    +
    + +
    +
    +

    Security & API Scopes

    +

    HoloBridge provides granular access control through API scopes, allowing you to create API keys with limited permissions.

    + +

    API Key Configuration

    + +

    Single Key (Simple)

    +

    For basic setups, use a single API key in .env:

    +
    API_KEY=your_secure_api_key
    +

    This key has admin scope (full access).

    + +

    Multiple Keys with Scopes

    +

    For production, define multiple keys with specific permissions using the API_KEYS environment variable:

    +
    API_KEYS=[
    +  {"id":"dashboard","name":"Web Dashboard","key":"dash_xxx","scopes":["read:guilds","read:members"]},
    +  {"id":"bot","name":"Chat Bot","key":"bot_xxx","scopes":["read:messages","write:messages"]},
    +  {"id":"admin","name":"Admin Panel","key":"admin_xxx","scopes":["admin"]}
    +]
    + +

    Available Scopes

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ScopePermissions
    read:guildsList guilds, get guild details
    read:channelsList channels, get channel info
    read:membersList members, get member details
    read:messagesRead message history
    write:messagesSend, edit, delete messages
    write:membersKick, ban, timeout members
    write:channelsCreate, edit, delete channels
    write:rolesCreate, edit, delete roles
    eventsSubscribe to WebSocket events
    adminFull access (bypasses all checks)
    + +

    Rate Limiting

    +

    HoloBridge includes built-in rate limiting to protect against abuse.

    + +

    Configuration

    +
    RATE_LIMIT_ENABLED=true
    +RATE_LIMIT_WINDOW_MS=60000  # 1 minute window
    +RATE_LIMIT_MAX=100          # 100 requests per window
    + +

    Response Headers

    +

    All API responses include rate limit headers:

    + + + + + + + + + + + + + + + + + + + + + +
    HeaderDescription
    X-RateLimit-LimitMaximum requests per window
    X-RateLimit-RemainingRequests remaining in current window
    X-RateLimit-ResetUnix timestamp when limit resets
    + +

    Rate Limited Response

    +

    When the limit is exceeded, you'll receive:

    +
    {
    +  "success": false,
    +  "error": "Too many requests",
    +  "code": "RATE_LIMITED",
    +  "retryAfter": 45
    +}
    + +
    + ⚠️ Best Practices: +
      +
    • Use scoped keys — give each integration only the permissions it needs
    • +
    • Rotate keys regularly — update API keys periodically
    • +
    • Keep admin keys secure — only use admin scope for trusted applications
    • +
    • Monitor usage — watch rate limit headers to identify issues
    • +
    +
    + +

    Next Steps

    + +
    +
    + + + + + + + diff --git a/docs/websocket.html b/docs/websocket.html index a49c20b..0d57ce5 100644 --- a/docs/websocket.html +++ b/docs/websocket.html @@ -18,6 +18,7 @@ Getting Started API Reference WebSocket + Security diff --git a/package.json b/package.json index ccd16e1..d5c8ac5 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,26 @@ { - "name": "discord-web-bridge", - "version": "1.0.0", + "name": "holobridge", + "version": "1.1.0", "type": "module", - "description": "A TypeScript bridge between websites and Discord bots", + "description": "A TypeScript bridge between websites and Discord bots with REST API, WebSocket, and Plugin support", "main": "dist/index.js", + "bin": { + "holo": "./bin/holo.js" + }, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "doctor": "node bin/holo.js doctor" }, + "keywords": [ + "discord", + "bot", + "api", + "bridge", + "websocket", + "plugins" + ], "dependencies": { "discord.js": "^14.14.1", "express": "^4.18.2", @@ -24,4 +36,4 @@ "typescript": "^5.3.2", "tsx": "^4.6.0" } -} +} \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..3de6cfe --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,307 @@ +# HoloBridge Plugins + +Plugins extend HoloBridge functionality without modifying the core codebase. + +## Quick Start + +Create a `.js` file in this directory: + +```javascript +// plugins/my-plugin.js +export default { + metadata: { + name: 'my-plugin', + version: '1.0.0', + author: 'Your Name', + description: 'What this plugin does', + }, + + // Register REST API routes (optional) + routes(router, ctx) { + router.get('/status', (req, res) => { + res.json({ status: 'ok' }); + }); + }, + + // Subscribe to events (optional) + events(on, ctx) { + return [ + on.onDiscord('messageCreate', (msg) => { + console.log('New message:', msg.content); + }), + ]; + }, + + onLoad(ctx) { + ctx.logger.info('Plugin loaded!'); + }, +}; +``` + +## Plugin Interface + +### Metadata (Required) + +```javascript +metadata: { + name: string, // Unique plugin identifier + version: string, // Semantic version (e.g., "1.0.0") + author?: string, // Optional author name + description?: string // Optional description +} +``` + +### REST API Endpoints + +Plugins can register REST endpoints mounted at `/api/plugins/{plugin-name}/`: + +```javascript +routes(router, ctx) { + // GET /api/plugins/my-plugin/status + router.get('/status', (req, res) => { + res.json({ success: true, data: { uptime: process.uptime() } }); + }); + + // POST /api/plugins/my-plugin/action + router.post('/action', (req, res) => { + const { value } = req.body; + res.json({ success: true, data: { received: value } }); + }); + + // Available methods: get, post, put, patch, delete, use +} +``` + +Routes automatically: +- Inherit API key authentication from `/api` +- Have error handling wrapped (errors return 500 JSON responses) +- Are scoped to your plugin's namespace + +### Event Subscriptions + +Subscribe to Discord events and custom inter-plugin events: + +```javascript +events(on, ctx) { + return [ + // Discord events + on.onDiscord('messageCreate', (msg) => { + if (msg.content === '!hello') { + console.log('Got hello from', msg.author.username); + } + }), + + // Custom events from other plugins + on.onCustom('other-plugin:action', (data) => { + console.log('Received:', data); + }), + + // Plugin lifecycle events + on.onPluginLoaded(({ name, version }) => { + console.log(`${name} v${version} loaded`); + }), + ]; +} +``` + +**Emit custom events** for other plugins to consume: + +```javascript +// In routes or onLoad +ctx.eventBus.emitCustom('my-plugin:user-action', { + userId: '123', + action: 'purchase', +}); + +// Or using the events helper +on.emit('my-plugin:user-action', { userId: '123' }); +``` + +### Lifecycle Hooks + +#### `onLoad(ctx)` + +Called when the plugin is loaded at server startup. + +```javascript +onLoad(ctx) { + ctx.logger.info('Hello from my plugin!'); + ctx.logger.info(`Connected to ${ctx.client.guilds.cache.size} guilds`); + ctx.logger.info(`Other plugins: ${ctx.listPlugins().join(', ')}`); +} +``` + +#### `onUnload()` + +Called when the server is shutting down. + +```javascript +onUnload() { + // Cleanup resources, close connections, etc. +} +``` + +#### `onEvent(eventName, data)` (Legacy) + +> **Deprecated**: Use `events()` hook instead for typed subscriptions. + +```javascript +onEvent(eventName, data) { + if (eventName === 'messageCreate') { + console.log(`New message: ${data.content}`); + } +} +``` + +## Plugin Context + +The `ctx` object passed to hooks provides: + +| Property | Type | Description | +|----------|------|-------------| +| `client` | `Discord.Client` | Full Discord.js client instance | +| `io` | `Socket.IO Server` | WebSocket server for custom events | +| `config` | `Config` | HoloBridge configuration | +| `app` | `Express` | Express application instance | +| `eventBus` | `PluginEventBus` | Event bus for inter-plugin communication | +| `logger` | `PluginLogger` | Prefixed logger with `info`, `warn`, `error`, `debug` | +| `log` | `(msg) => void` | Simple legacy logger | +| `getPlugin` | `(name) => metadata` | Get another plugin's metadata | +| `listPlugins` | `() => string[]` | List all loaded plugin names | + +## Event Types + +### Discord Events + +All Discord.js events are available with the `discord:` prefix internally: + +| Category | Events | +|----------|--------| +| Messages | `messageCreate`, `messageUpdate`, `messageDelete`, `messageReactionAdd`, etc. | +| Members | `guildMemberAdd`, `guildMemberRemove`, `guildMemberUpdate`, `presenceUpdate` | +| Channels | `channelCreate`, `channelUpdate`, `channelDelete`, `threadCreate` | +| Guilds | `guildCreate`, `guildUpdate`, `guildDelete`, `guildBanAdd` | +| Roles | `roleCreate`, `roleUpdate`, `roleDelete` | +| Voice | `voiceStateUpdate` | +| And more... | See [events.types.ts](../src/types/events.types.ts) | + +### Plugin Lifecycle Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `plugin:loaded` | `{ name, version }` | A plugin was loaded | +| `plugin:unloaded` | `{ name }` | A plugin was unloaded | +| `plugin:error` | `{ name, error }` | A plugin encountered an error | + +### Custom Events + +Plugins can emit any custom event with `custom:` prefix: + +```javascript +ctx.eventBus.emitCustom('my-plugin:something', { key: 'value' }); +``` + +## Examples + +### Auto-Responder with API + +```javascript +export default { + metadata: { name: 'auto-responder', version: '1.0.0' }, + + triggers: new Map(), + + routes(router) { + router.get('/triggers', (req, res) => { + res.json({ success: true, data: Object.fromEntries(this.triggers) }); + }); + + router.post('/triggers', (req, res) => { + const { trigger, response } = req.body; + this.triggers.set(trigger, response); + res.json({ success: true }); + }); + }, + + events(on, ctx) { + return [ + on.onDiscord('messageCreate', async (msg) => { + if (msg.author?.bot) return; + + const response = this.triggers.get(msg.content); + if (response) { + const channel = await ctx.client.channels.fetch(msg.channelId); + if (channel?.isTextBased()) { + await channel.send(response); + } + } + }), + ]; + }, +}; +``` + +### Event Logger + +```javascript +export default { + metadata: { name: 'event-logger', version: '1.0.0' }, + + events(on) { + const eventTypes = ['messageCreate', 'guildMemberAdd', 'roleCreate']; + return eventTypes.map(event => + on.onDiscord(event, () => { + console.log(`[${new Date().toISOString()}] ${event}`); + }) + ); + }, +}; +``` + +### Cross-Plugin Communication + +```javascript +// Plugin A: Emits events +export default { + metadata: { name: 'plugin-a', version: '1.0.0' }, + + events(on, ctx) { + return [ + on.onDiscord('guildMemberAdd', (member) => { + on.emit('member:welcomed', { + userId: member.user?.id, + guildId: member.guildId, + }); + }), + ]; + }, +}; + +// Plugin B: Listens to Plugin A +export default { + metadata: { name: 'plugin-b', version: '1.0.0' }, + + events(on, ctx) { + return [ + on.onCustom('member:welcomed', (data) => { + ctx.logger.info(`Member ${data.userId} was welcomed`); + }), + ]; + }, +}; +``` + +## Best Practices + +1. **Use typed events** - Subscribe via `events()` hook for automatic cleanup +2. **Handle errors** - Route handlers are wrapped, but catch errors in event handlers +3. **Clean up** - Use `onUnload` to close connections and clear timers +4. **Be selective** - Filter events early to avoid unnecessary processing +5. **Log sparingly** - Use `ctx.logger.debug()` for verbose logs (only shown in debug mode) +6. **Namespace events** - Prefix custom events with your plugin name + +## Disabling Plugins + +To disable a plugin, either: +- Delete or rename the file (e.g., `my-plugin.js.disabled`) +- Set `PLUGINS_ENABLED=false` in `.env` to disable all plugins diff --git a/plugins/example-api-plugin.js b/plugins/example-api-plugin.js new file mode 100644 index 0000000..4ebcf83 --- /dev/null +++ b/plugins/example-api-plugin.js @@ -0,0 +1,181 @@ +/** + * Example REST API Plugin for HoloBridge + * + * This plugin demonstrates how to create a full CRUD REST API + * with validation and persistent state. + */ + +// In-memory storage for demo (replace with database in production) +const notes = new Map(); +let nextId = 1; + +export default { + metadata: { + name: 'notes-api', + version: '1.0.0', + author: 'HoloBridge', + description: 'A simple notes API demonstrating plugin REST endpoints', + }, + + routes(router, ctx) { + /** + * GET /api/plugins/notes-api/notes + * List all notes + */ + router.get('/notes', (req, res) => { + const allNotes = Array.from(notes.values()); + res.json({ success: true, data: allNotes }); + }); + + /** + * GET /api/plugins/notes-api/notes/:id + * Get a specific note + */ + router.get('/notes/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + const note = notes.get(id); + + if (!note) { + res.status(404).json({ + success: false, + error: 'Note not found', + code: 'NOTE_NOT_FOUND', + }); + return; + } + + res.json({ success: true, data: note }); + }); + + /** + * POST /api/plugins/notes-api/notes + * Create a new note + */ + router.post('/notes', (req, res) => { + const { title, content } = req.body; + + if (!title || typeof title !== 'string') { + res.status(400).json({ + success: false, + error: 'Title is required', + code: 'VALIDATION_ERROR', + }); + return; + } + + const note = { + id: nextId++, + title, + content: content || '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + notes.set(note.id, note); + + // Emit event for other plugins + ctx.eventBus.emitCustom('notes:created', { note }); + + res.status(201).json({ success: true, data: note }); + }); + + /** + * PATCH /api/plugins/notes-api/notes/:id + * Update a note + */ + router.patch('/notes/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + const note = notes.get(id); + + if (!note) { + res.status(404).json({ + success: false, + error: 'Note not found', + code: 'NOTE_NOT_FOUND', + }); + return; + } + + const { title, content } = req.body; + + // Validate title if provided + if (title !== undefined) { + if (typeof title !== 'string' || title.trim().length === 0) { + res.status(400).json({ + success: false, + error: 'Title must be a non-empty string', + code: 'VALIDATION_ERROR', + }); + return; + } + note.title = title; + } + + if (content !== undefined) note.content = content; + note.updatedAt = new Date().toISOString(); + + ctx.eventBus.emitCustom('notes:updated', { note }); + + res.json({ success: true, data: note }); + }); + + /** + * DELETE /api/plugins/notes-api/notes/:id + * Delete a note + */ + router.delete('/notes/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + + if (!notes.has(id)) { + res.status(404).json({ + success: false, + error: 'Note not found', + code: 'NOTE_NOT_FOUND', + }); + return; + } + + notes.delete(id); + ctx.eventBus.emitCustom('notes:deleted', { id }); + + res.json({ success: true, message: 'Note deleted' }); + }); + + /** + * GET /api/plugins/notes-api/stats + * Get notes statistics + */ + router.get('/stats', (req, res) => { + const allNotes = Array.from(notes.values()); + res.json({ + success: true, + data: { + total: allNotes.length, + averageContentLength: allNotes.length > 0 + ? Math.round(allNotes.reduce((sum, n) => sum + n.content.length, 0) / allNotes.length) + : 0, + }, + }); + }); + }, + + events(on, ctx) { + return [ + // Listen for own events (for logging) + on.onCustom('notes:created', (data) => { + ctx.logger.info(`Note created: "${data.note.title}"`); + }), + ]; + }, + + onLoad(ctx) { + ctx.logger.info('Notes API plugin loaded!'); + ctx.logger.info('Endpoints available at /api/plugins/notes-api/'); + }, + + onUnload() { + // Clear data on unload + notes.clear(); + console.log('[notes-api] Cleaned up'); + }, +}; diff --git a/plugins/example-plugin.js b/plugins/example-plugin.js new file mode 100644 index 0000000..12dc7ab --- /dev/null +++ b/plugins/example-plugin.js @@ -0,0 +1,110 @@ +/** + * Example HoloBridge Plugin + * + * This plugin demonstrates the plugin system capabilities including: + * - Event subscriptions (both Discord and custom events) + * - REST API endpoints + * - Inter-plugin communication + */ + +export default { + metadata: { + name: 'example-plugin', + version: '2.0.0', + author: 'HoloBridge', + description: 'An example plugin demonstrating events and REST endpoints', + }, + + /** + * Register REST API routes for this plugin. + * Routes are mounted at /api/plugins/example-plugin/ + */ + routes(router, ctx) { + // GET /api/plugins/example-plugin/status + router.get('/status', (req, res) => { + res.json({ + success: true, + data: { + status: 'ok', + guilds: ctx.client.guilds.cache.size, + uptime: process.uptime(), + }, + }); + }); + + // GET /api/plugins/example-plugin/guilds + router.get('/guilds', (req, res) => { + const guilds = ctx.client.guilds.cache.map(g => ({ + id: g.id, + name: g.name, + memberCount: g.memberCount, + })); + res.json({ success: true, data: guilds }); + }); + + // POST /api/plugins/example-plugin/emit-test + router.post('/emit-test', (req, res) => { + const { message } = req.body; + // Emit a custom event that other plugins can listen to + ctx.eventBus.emitCustom('example:test-event', { + message: message || 'Hello from example plugin!', + timestamp: Date.now(), + }); + res.json({ success: true, message: 'Event emitted' }); + }); + }, + + /** + * Set up event subscriptions. + * Return an array of subscriptions for automatic cleanup. + */ + events(on, ctx) { + return [ + // Subscribe to Discord message events + on.onDiscord('messageCreate', (msg) => { + if (msg.author?.bot) return; + + if (msg.content === '!ping') { + ctx.logger.info(`Received ping from ${msg.author.username}`); + } + }), + + // Subscribe to guild member join events + on.onDiscord('guildMemberAdd', (member) => { + ctx.logger.info(`New member joined: ${member.user?.username}`); + // Emit a custom event for other plugins + on.emit('example:member-joined', { + userId: member.user?.id, + username: member.user?.username, + guildId: member.guildId, + }); + }), + + // Listen for events from other plugins + on.onCustom('other-plugin:action', (data) => { + ctx.logger.debug('Received event from another plugin:', data); + }), + + // React when another plugin loads + on.onPluginLoaded((data) => { + ctx.logger.info(`Plugin loaded: ${data.name} v${data.version}`); + }), + ]; + }, + + /** + * Called when the plugin is loaded. + */ + onLoad(ctx) { + ctx.logger.info('Example plugin v2.0.0 loaded!'); + ctx.logger.info(`Connected to ${ctx.client.guilds.cache.size} guild(s)`); + ctx.logger.info(`Other plugins: ${ctx.listPlugins().join(', ') || 'none yet'}`); + }, + + /** + * Called when the plugin is unloaded. + */ + onUnload() { + console.log('[example-plugin] Goodbye!'); + }, +}; diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 0ae3df0..c7ceb29 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -1,13 +1,46 @@ -import type { Request, Response, NextFunction } from 'express'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { config } from '../../config/index.js'; +import type { ApiScope, ApiKeyRecord } from '../../types/auth.types.js'; +/** + * Extended request with API key context + */ export interface AuthenticatedRequest extends Request { - apiKey: string; + apiKey: ApiKeyRecord; +} + +/** + * Find an API key record by its key value. + * Checks both the new apiKeys array and legacy single apiKey. + */ +function findApiKey(key: string): ApiKeyRecord | null { + // Check new multi-key system first + const found = config.api.apiKeys.find((k) => k.key === key); + if (found) { + return { + ...found, + scopes: found.scopes as ApiScope[], + createdAt: found.createdAt || new Date(), + }; + } + + // Fall back to legacy single key (has admin scope) + if (key === config.api.apiKey) { + return { + id: 'legacy', + name: 'Legacy API Key', + key: key, + scopes: ['admin'], + createdAt: new Date(), + }; + } + + return null; } /** - * API Key authentication middleware - * Checks for X-API-Key header and validates against configured key + * API Key authentication middleware. + * Validates API key and attaches key context to request. */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { const apiKey = req.headers['x-api-key']; @@ -21,7 +54,8 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return; } - if (apiKey !== config.api.apiKey) { + const keyRecord = findApiKey(apiKey); + if (!keyRecord) { res.status(401).json({ success: false, error: 'Invalid API key', @@ -30,10 +64,56 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return; } - (req as AuthenticatedRequest).apiKey = apiKey; + // Attach key record to request for downstream use + (req as AuthenticatedRequest).apiKey = keyRecord; next(); } +/** + * Middleware factory to require specific scope(s). + * Use after authMiddleware to enforce granular permissions. + * + * @example + * router.post('/messages', requireScope('write:messages'), handler); + */ +export function requireScope(...requiredScopes: ApiScope[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const keyRecord = (req as AuthenticatedRequest).apiKey; + + if (!keyRecord) { + res.status(401).json({ + success: false, + error: 'Not authenticated', + code: 'NOT_AUTHENTICATED', + }); + return; + } + + // Admin scope bypasses all checks + if (keyRecord.scopes.includes('admin')) { + return next(); + } + + // Check if key has all required scopes + const hasAllScopes = requiredScopes.every((scope) => + keyRecord.scopes.includes(scope) + ); + + if (!hasAllScopes) { + res.status(403).json({ + success: false, + error: `Missing required scope(s): ${requiredScopes.join(', ')}`, + code: 'INSUFFICIENT_SCOPE', + required: requiredScopes, + granted: keyRecord.scopes, + }); + return; + } + + next(); + }; +} + /** * Error handler middleware */ @@ -58,3 +138,4 @@ export function notFoundHandler(req: Request, res: Response): void { code: 'NOT_FOUND', }); } + diff --git a/src/api/middleware/rateLimit.ts b/src/api/middleware/rateLimit.ts new file mode 100644 index 0000000..71cd672 --- /dev/null +++ b/src/api/middleware/rateLimit.ts @@ -0,0 +1,163 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { config } from '../../config/index.js'; + +/** + * Simple in-memory rate limiter. + * For production, consider using express-rate-limit with Redis store. + */ +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const rateLimitStore = new Map(); + +/** + * Get client identifier for rate limiting (IP address). + */ +function getClientId(req: Request): string { + // Support X-Forwarded-For for proxied requests + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0]?.trim() ?? req.ip ?? 'unknown'; + } + return req.ip ?? 'unknown'; +} + + +/** + * Global rate limiter middleware. + * Uses configuration from config.rateLimit. + */ +export function rateLimiter(): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + if (!config.rateLimit.enabled) { + return next(); + } + + const clientId = getClientId(req); + const now = Date.now(); + const windowMs = config.rateLimit.windowMs; + const maxRequests = config.rateLimit.maxRequests; + + let entry = rateLimitStore.get(clientId); + + // Create new entry or reset if window expired + if (!entry || entry.resetAt < now) { + entry = { + count: 0, + resetAt: now + windowMs, + }; + rateLimitStore.set(clientId, entry); + } + + entry.count++; + + // Set rate limit headers + const remaining = Math.max(0, maxRequests - entry.count); + res.setHeader('X-RateLimit-Limit', maxRequests); + res.setHeader('X-RateLimit-Remaining', remaining); + res.setHeader('X-RateLimit-Reset', Math.ceil(entry.resetAt / 1000)); + + if (entry.count > maxRequests) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.setHeader('Retry-After', retryAfter); + res.status(429).json({ + success: false, + error: 'Too many requests', + code: 'RATE_LIMITED', + retryAfter, + }); + return; + } + + next(); + }; +} + +/** + * Create a strict rate limiter for specific routes. + * @param maxRequests - Max requests allowed + * @param windowMs - Time window in milliseconds + * @param cleanupIntervalMs - Cleanup interval in milliseconds (default: 60000) + */ +export function strictRateLimiter( + maxRequests: number, + windowMs: number, + cleanupIntervalMs: number = 60000 +): RequestHandler { + const store = new Map(); + + // Set up periodic cleanup for this store + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (entry.resetAt < now) { + store.delete(key); + } + } + }, cleanupIntervalMs); + + // Track interval for shutdown cleanup + cleanupIntervals.push(cleanupInterval); + + return (req: Request, res: Response, next: NextFunction): void => { + const clientId = getClientId(req); + const now = Date.now(); + + let entry = store.get(clientId); + + if (!entry || entry.resetAt < now) { + entry = { + count: 0, + resetAt: now + windowMs, + }; + store.set(clientId, entry); + } + + entry.count++; + + if (entry.count > maxRequests) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.setHeader('Retry-After', retryAfter); + res.status(429).json({ + success: false, + error: 'Rate limit exceeded for this endpoint', + code: 'RATE_LIMITED', + retryAfter, + }); + return; + } + + next(); + }; +} + +/** + * Track all cleanup intervals for proper shutdown + */ +const cleanupIntervals: NodeJS.Timeout[] = []; + +// Track the global cleanup interval +const globalCleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of rateLimitStore) { + if (entry.resetAt < now) { + rateLimitStore.delete(key); + } + } +}, 60000); +cleanupIntervals.push(globalCleanupInterval); + +/** + * Clean up all rate limiter intervals on shutdown. + * Call this when the server is shutting down to prevent memory leaks. + */ +export function shutdownRateLimiter(): void { + for (const interval of cleanupIntervals) { + clearInterval(interval); + } + cleanupIntervals.length = 0; + rateLimitStore.clear(); +} + diff --git a/src/api/routes/automod.ts b/src/api/routes/automod.ts new file mode 100644 index 0000000..5334228 --- /dev/null +++ b/src/api/routes/automod.ts @@ -0,0 +1,199 @@ +import { Router } from 'express'; +import type { Request } from 'express'; +import { z } from 'zod'; +import { autoModService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedAutoModRule } from '../../types/discord.types.js'; + +/** Route params for guild-level endpoints */ +interface GuildParams { + guildId: string; +} + +/** Route params for rule-specific endpoints */ +interface GuildRuleParams extends GuildParams { + ruleId: string; +} + +/** + * Zod schema for AutoMod action + */ +const autoModActionSchema = z.object({ + type: z.number().int().min(1).max(4), // 1=BlockMessage, 2=SendAlertMessage, 3=Timeout, 4=BlockMemberInteraction + metadata: z.object({ + channelId: z.string().optional(), + durationSeconds: z.number().int().min(0).max(2419200).optional(), // Max 28 days + customMessage: z.string().max(150).optional(), + }).optional(), +}); + +/** + * Zod schema for AutoMod trigger metadata + */ +const triggerMetadataSchema = z.object({ + keywordFilter: z.array(z.string().max(60)).max(1000).optional(), + regexPatterns: z.array(z.string().max(260)).max(10).optional(), + presets: z.array(z.number().int().min(1).max(3)).optional(), // 1=Profanity, 2=SexualContent, 3=Slurs + allowList: z.array(z.string().max(60)).max(100).optional(), + mentionTotalLimit: z.number().int().min(1).max(50).optional(), + mentionRaidProtectionEnabled: z.boolean().optional(), +}).optional(); + +/** + * Zod schema for creating an AutoMod rule + * Validates against Discord's AutoModerationRuleCreateOptions + */ +const createAutoModRuleSchema = z.object({ + name: z.string().min(1).max(100), + eventType: z.number().int().min(1).max(1), // Currently only 1 (MessageSend) is valid + triggerType: z.number().int().min(1).max(6), // 1=Keyword, 3=Spam, 4=KeywordPreset, 5=MentionSpam, 6=MemberProfile + actions: z.array(autoModActionSchema).min(1).max(5), + triggerMetadata: triggerMetadataSchema, + enabled: z.boolean().optional().default(true), + exemptRoles: z.array(z.string()).max(20).optional(), + exemptChannels: z.array(z.string()).max(50).optional(), + reason: z.string().max(512).optional(), +}); + +/** + * Zod schema for updating an AutoMod rule (all fields optional) + */ +const updateAutoModRuleSchema = createAutoModRuleSchema.partial(); + +const router = Router({ mergeParams: true }); + +/** + * GET /api/guilds/:guildId/auto-moderation/rules + * List all auto-moderation rules in a guild + */ +router.get('/rules', async (req: Request, res) => { + try { + const { guildId } = req.params; + const rules = await autoModService.getAutoModRules(guildId); + const response: ApiResponse = { success: true, data: rules }; + res.json(response); + } catch (error) { + console.error('Error fetching auto-moderation rules:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * GET /api/guilds/:guildId/auto-moderation/rules/:ruleId + * Get a specific auto-moderation rule + */ +router.get('/rules/:ruleId', async (req: Request, res) => { + try { + const { guildId, ruleId } = req.params; + const rule = await autoModService.getAutoModRule(guildId, ruleId); + + if (!rule) { + res.status(404).json({ success: false, error: 'Rule not found', code: 'RULE_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: rule }; + res.json(response); + } catch (error) { + console.error('Error fetching auto-moderation rule:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * POST /api/guilds/:guildId/auto-moderation/rules + * Create a new auto-moderation rule + */ +router.post('/rules', async (req: Request, res) => { + try { + const { guildId } = req.params; + + // Validate request body + const parseResult = createAutoModRuleSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + error: 'Validation failed', + code: 'VALIDATION_ERROR', + issues: parseResult.error.issues.map(issue => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }); + return; + } + + const validatedData = parseResult.data; + const rule = await autoModService.createAutoModRule(guildId, validatedData); + + if (!rule) { + res.status(400).json({ success: false, error: 'Failed to create rule', code: 'RULE_CREATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: rule }; + res.status(201).json(response); + } catch (error) { + console.error('Error creating auto-moderation rule:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * PATCH /api/guilds/:guildId/auto-moderation/rules/:ruleId + * Edit an auto-moderation rule + */ +router.patch('/rules/:ruleId', async (req: Request, res) => { + try { + const { guildId, ruleId } = req.params; + + // Validate request body + const parseResult = updateAutoModRuleSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + error: 'Invalid request body', + code: 'VALIDATION_ERROR', + details: parseResult.error.issues, + }); + return; + } + + const validatedData = parseResult.data; + const rule = await autoModService.editAutoModRule(guildId, ruleId, validatedData); + + if (!rule) { + res.status(404).json({ success: false, error: 'Rule not found or failed to update', code: 'RULE_UPDATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: rule }; + res.json(response); + } catch (error) { + console.error('Error updating auto-moderation rule:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * DELETE /api/guilds/:guildId/auto-moderation/rules/:ruleId + * Delete an auto-moderation rule + */ +router.delete('/rules/:ruleId', async (req: Request, res) => { + try { + const { guildId, ruleId } = req.params; + const success = await autoModService.deleteAutoModRule(guildId, ruleId); + + if (!success) { + res.status(404).json({ success: false, error: 'Rule not found or failed to delete', code: 'RULE_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting auto-moderation rule:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +export default router; diff --git a/src/api/routes/emojis.ts b/src/api/routes/emojis.ts new file mode 100644 index 0000000..edcc7ef --- /dev/null +++ b/src/api/routes/emojis.ts @@ -0,0 +1,123 @@ +import { Router } from 'express'; +import type { Request } from 'express'; +import { emojiService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedEmoji } from '../../types/discord.types.js'; + +/** Route params for guild-level endpoints */ +interface GuildParams { + guildId: string; +} + +/** Route params for emoji-specific endpoints */ +interface GuildEmojiParams extends GuildParams { + emojiId: string; +} + +const router = Router({ mergeParams: true }); + +/** + * GET /api/guilds/:guildId/emojis + * List all emojis in a guild + */ +router.get('/', async (req: Request, res) => { + try { + const { guildId } = req.params; + const emojis = await emojiService.getGuildEmojis(guildId); + const response: ApiResponse = { success: true, data: emojis }; + res.json(response); + } catch (error) { + console.error('Error fetching emojis:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * GET /api/guilds/:guildId/emojis/:emojiId + * Get a specific emoji + */ +router.get('/:emojiId', async (req: Request, res) => { + try { + const { guildId, emojiId } = req.params; + const emoji = await emojiService.getEmoji(guildId, emojiId); + + if (!emoji) { + res.status(404).json({ success: false, error: 'Emoji not found', code: 'EMOJI_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: emoji }; + res.json(response); + } catch (error) { + console.error('Error fetching emoji:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * POST /api/guilds/:guildId/emojis + * Create a new emoji + */ +router.post('/', async (req: Request, res) => { + try { + const { guildId } = req.params; + const emoji = await emojiService.createEmoji(guildId, req.body); + + if (!emoji) { + res.status(400).json({ success: false, error: 'Failed to create emoji', code: 'EMOJI_CREATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: emoji }; + res.status(201).json(response); + } catch (error) { + console.error('Error creating emoji:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * PATCH /api/guilds/:guildId/emojis/:emojiId + * Edit an emoji + */ +router.patch('/:emojiId', async (req: Request, res) => { + try { + const { guildId, emojiId } = req.params; + const emoji = await emojiService.editEmoji(guildId, emojiId, req.body); + + if (!emoji) { + res.status(404).json({ success: false, error: 'Emoji not found or failed to update', code: 'EMOJI_UPDATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: emoji }; + res.json(response); + } catch (error) { + console.error('Error updating emoji:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * DELETE /api/guilds/:guildId/emojis/:emojiId + * Delete an emoji + */ +router.delete('/:emojiId', async (req: Request, res) => { + try { + const { guildId, emojiId } = req.params; + const success = await emojiService.deleteEmoji(guildId, emojiId); + + if (!success) { + res.status(404).json({ success: false, error: 'Emoji not found or failed to delete', code: 'EMOJI_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting emoji:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +export default router; + diff --git a/src/api/routes/invites.ts b/src/api/routes/invites.ts new file mode 100644 index 0000000..647285a --- /dev/null +++ b/src/api/routes/invites.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { inviteService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedInvite } from '../../types/discord.types.js'; + +const router = Router(); + +/** + * GET /api/invites/:code + * Get an invite by code + */ +router.get('/:code', async (req, res) => { + try { + const { code } = req.params; + const invite = await inviteService.getInvite(code); + + if (!invite) { + res.status(404).json({ success: false, error: 'Invite not found', code: 'INVITE_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: invite }; + res.json(response); + } catch (error) { + console.error('Error fetching invite:', error); + const isDev = process.env.NODE_ENV !== 'production'; + res.status(500).json({ + success: false, + error: 'Internal server error', + code: 'INTERNAL_ERROR', + ...(isDev && error instanceof Error && { details: error.message }) + }); + return; + } +}); + +/** + * DELETE /api/invites/:code + * Delete an invite + */ +router.delete('/:code', async (req, res) => { + try { + const { code } = req.params; + const success = await inviteService.deleteInvite(code); + + if (!success) { + res.status(404).json({ success: false, error: 'Invite not found or failed to delete', code: 'INVITE_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting invite:', error); + const isDev = process.env.NODE_ENV !== 'production'; + res.status(500).json({ + success: false, + error: 'Internal server error', + code: 'INTERNAL_ERROR', + ...(isDev && error instanceof Error && { details: error.message }) + }); + return; + } +}); + +export default router; diff --git a/src/api/routes/scheduled-events.ts b/src/api/routes/scheduled-events.ts new file mode 100644 index 0000000..a0ebf07 --- /dev/null +++ b/src/api/routes/scheduled-events.ts @@ -0,0 +1,139 @@ +import { Router } from 'express'; +import type { Request } from 'express'; +import { scheduledEventService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedScheduledEvent, SerializedUser } from '../../types/discord.types.js'; + +/** Route params for guild-level endpoints */ +interface GuildParams { + guildId: string; +} + +/** Route params for event-specific endpoints */ +interface GuildEventParams extends GuildParams { + eventId: string; +} + +const router = Router({ mergeParams: true }); + +/** + * GET /api/guilds/:guildId/scheduled-events + * List all scheduled events in a guild + */ +router.get('/', async (req: Request, res) => { + try { + const { guildId } = req.params; + const events = await scheduledEventService.getGuildEvents(guildId); + const response: ApiResponse = { success: true, data: events }; + res.json(response); + } catch (error) { + console.error('Error fetching scheduled events:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * GET /api/guilds/:guildId/scheduled-events/:eventId + * Get a specific scheduled event + */ +router.get('/:eventId', async (req: Request, res) => { + try { + const { guildId, eventId } = req.params; + const event = await scheduledEventService.getEvent(guildId, eventId); + + if (!event) { + res.status(404).json({ success: false, error: 'Event not found', code: 'EVENT_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: event }; + res.json(response); + } catch (error) { + console.error('Error fetching scheduled event:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * POST /api/guilds/:guildId/scheduled-events + * Create a new scheduled event + */ +router.post('/', async (req: Request, res) => { + try { + const { guildId } = req.params; + const event = await scheduledEventService.createEvent(guildId, req.body); + + if (!event) { + res.status(400).json({ success: false, error: 'Failed to create event', code: 'EVENT_CREATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: event }; + res.status(201).json(response); + } catch (error) { + console.error('Error creating scheduled event:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * PATCH /api/guilds/:guildId/scheduled-events/:eventId + * Edit a scheduled event + */ +router.patch('/:eventId', async (req: Request, res) => { + try { + const { guildId, eventId } = req.params; + const event = await scheduledEventService.editEvent(guildId, eventId, req.body); + + if (!event) { + res.status(404).json({ success: false, error: 'Event not found or failed to update', code: 'EVENT_UPDATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: event }; + res.json(response); + } catch (error) { + console.error('Error updating scheduled event:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * DELETE /api/guilds/:guildId/scheduled-events/:eventId + * Delete a scheduled event + */ +router.delete('/:eventId', async (req: Request, res) => { + try { + const { guildId, eventId } = req.params; + const success = await scheduledEventService.deleteEvent(guildId, eventId); + + if (!success) { + res.status(404).json({ success: false, error: 'Event not found or failed to delete', code: 'EVENT_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting scheduled event:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +/** + * GET /api/guilds/:guildId/scheduled-events/:eventId/users + * Get users subscribed to an event + */ +router.get('/:eventId/users', async (req: Request, res) => { + try { + const { guildId, eventId } = req.params; + const users = await scheduledEventService.getEventUsers(guildId, eventId); + const response: ApiResponse = { success: true, data: users }; + res.json(response); + } catch (error) { + console.error('Error fetching event users:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_SERVER_ERROR' }); + } +}); + +export default router; + diff --git a/src/api/routes/stage-instances.ts b/src/api/routes/stage-instances.ts new file mode 100644 index 0000000..5f870d4 --- /dev/null +++ b/src/api/routes/stage-instances.ts @@ -0,0 +1,119 @@ +import { Router } from 'express'; +import { stageInstanceService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedStageInstance } from '../../types/discord.types.js'; + +const router = Router(); + +/** + * GET /api/stage-instances/:channelId + * Get stage instance for a channel + */ +router.get('/:channelId', async (req, res) => { + const { channelId } = req.params; + + try { + const stageInstance = await stageInstanceService.getStageInstance(channelId); + + if (!stageInstance) { + res.status(404).json({ success: false, error: 'Stage instance not found', code: 'STAGE_INSTANCE_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: stageInstance }; + res.json(response); + } catch (error) { + console.error('Error fetching stage instance:', error); + res.status(500).json({ success: false, error: 'Failed to fetch stage instance', code: 'STAGE_INSTANCE_FETCH_ERROR' }); + } +}); + +/** + * POST /api/stage-instances + * Create a new stage instance + */ +router.post('/', async (req, res) => { + const { channelId, topic, ...options } = req.body; + + if (!channelId || !topic) { + res.status(400).json({ success: false, error: 'Missing channelId or topic', code: 'MISSING_FIELDS' }); + return; + } + + try { + const stageInstance = await stageInstanceService.createStageInstance(channelId, topic, options); + + if (!stageInstance) { + res.status(400).json({ success: false, error: 'Failed to create stage instance', code: 'STAGE_INSTANCE_CREATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: stageInstance }; + res.status(201).json(response); + } catch (error) { + console.error('Error creating stage instance:', error); + res.status(500).json({ success: false, error: 'Failed to create stage instance', code: 'STAGE_INSTANCE_CREATE_ERROR' }); + } +}); + +/** + * PATCH /api/stage-instances/:channelId + * Edit a stage instance + */ +router.patch('/:channelId', async (req, res) => { + const { channelId } = req.params; + + // Validate request body - only allow valid fields + const allowedFields = ['topic', 'privacyLevel']; + const updates: Record = {}; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates[field] = req.body[field]; + } + } + + if (Object.keys(updates).length === 0) { + res.status(400).json({ success: false, error: 'No valid fields provided', code: 'INVALID_REQUEST_BODY' }); + return; + } + + try { + const stageInstance = await stageInstanceService.editStageInstance(channelId, updates); + + if (!stageInstance) { + res.status(404).json({ success: false, error: 'Stage instance not found or failed to update', code: 'STAGE_INSTANCE_UPDATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: stageInstance }; + res.json(response); + } catch (error) { + console.error('Error updating stage instance:', error); + res.status(500).json({ success: false, error: 'Failed to update stage instance', code: 'STAGE_INSTANCE_UPDATE_ERROR' }); + } +}); + +/** + * DELETE /api/stage-instances/:channelId + * Delete a stage instance + */ +router.delete('/:channelId', async (req, res) => { + const { channelId } = req.params; + + try { + const success = await stageInstanceService.deleteStageInstance(channelId); + + if (!success) { + res.status(404).json({ success: false, error: 'Stage instance not found or failed to delete', code: 'STAGE_INSTANCE_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting stage instance:', error); + res.status(500).json({ success: false, error: 'Failed to delete stage instance', code: 'STAGE_INSTANCE_DELETE_ERROR' }); + } +}); + +export default router; diff --git a/src/api/routes/stickers.ts b/src/api/routes/stickers.ts new file mode 100644 index 0000000..5b9a1e1 --- /dev/null +++ b/src/api/routes/stickers.ts @@ -0,0 +1,111 @@ +import { Router } from 'express'; +import { stickerService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedSticker } from '../../types/discord.types.js'; + +const router = Router({ mergeParams: true }); + +/** + * GET /api/guilds/:guildId/stickers + * List all stickers in a guild + */ +router.get('/', async (req, res) => { + try { + const { guildId } = req.params as any; + const stickers = await stickerService.getGuildStickers(guildId as string); + const response: ApiResponse = { success: true, data: stickers }; + res.json(response); + } catch (error) { + console.error('Error fetching guild stickers:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'STICKER_LIST_ERROR' }); + } +}); + +/** + * GET /api/guilds/:guildId/stickers/:stickerId + * Get a specific sticker + */ +router.get('/:stickerId', async (req, res) => { + try { + const { guildId, stickerId } = req.params as any; + const sticker = await stickerService.getSticker(guildId as string, stickerId as string); + + if (!sticker) { + res.status(404).json({ success: false, error: 'Sticker not found', code: 'STICKER_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: sticker }; + res.json(response); + } catch (error) { + console.error('Error fetching sticker:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'STICKER_FETCH_ERROR' }); + } +}); + +/** + * POST /api/guilds/:guildId/stickers + * Create a new sticker + */ +router.post('/', async (req, res) => { + try { + const { guildId } = req.params as any; + const sticker = await stickerService.createSticker(guildId as string, req.body); + + if (!sticker) { + res.status(400).json({ success: false, error: 'Failed to create sticker', code: 'STICKER_CREATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: sticker }; + res.status(201).json(response); + } catch (error) { + console.error('Error creating sticker:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'STICKER_CREATE_ERROR' }); + } +}); + +/** + * PATCH /api/guilds/:guildId/stickers/:stickerId + * Edit a sticker + */ +router.patch('/:stickerId', async (req, res) => { + try { + const { guildId, stickerId } = req.params as any; + const sticker = await stickerService.editSticker(guildId as string, stickerId as string, req.body); + + if (!sticker) { + res.status(404).json({ success: false, error: 'Sticker not found or failed to update', code: 'STICKER_UPDATE_FAILED' }); + return; + } + + const response: ApiResponse = { success: true, data: sticker }; + res.json(response); + } catch (error) { + console.error('Error updating sticker:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'STICKER_UPDATE_ERROR' }); + } +}); + +/** + * DELETE /api/guilds/:guildId/stickers/:stickerId + * Delete a sticker + */ +router.delete('/:stickerId', async (req, res) => { + try { + const { guildId, stickerId } = req.params as any; + const success = await stickerService.deleteSticker(guildId as string, stickerId as string); + + if (!success) { + res.status(404).json({ success: false, error: 'Sticker not found or failed to delete', code: 'STICKER_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting sticker:', error); + res.status(500).json({ success: false, error: 'Internal server error', code: 'STICKER_DELETE_ERROR' }); + } +}); + +export default router; diff --git a/src/api/routes/webhooks.ts b/src/api/routes/webhooks.ts new file mode 100644 index 0000000..5213a4a --- /dev/null +++ b/src/api/routes/webhooks.ts @@ -0,0 +1,76 @@ +import { Router } from 'express'; +import { webhookService } from '../../discord/services/index.js'; +import type { ApiResponse } from '../../types/api.types.js'; +import type { SerializedWebhook } from '../../types/discord.types.js'; + +const router = Router(); + +/** + * GET /api/webhooks/:webhookId + * Get a specific webhook + */ +router.get('/:webhookId', async (req, res) => { + try { + const { webhookId } = req.params; + const webhook = await webhookService.getWebhook(webhookId); + + if (!webhook) { + res.status(404).json({ success: false, error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: webhook }; + res.json(response); + } catch (error) { + console.error('Error fetching webhook:', error); + res.status(500).json({ success: false, error: 'Failed to fetch webhook', code: 'WEBHOOK_FETCH_ERROR' }); + return; + } +}); + +/** + * PATCH /api/webhooks/:webhookId + * Edit a webhook + */ +router.patch('/:webhookId', async (req, res) => { + try { + const { webhookId } = req.params; + const webhook = await webhookService.editWebhook(webhookId, req.body); + + if (!webhook) { + res.status(404).json({ success: false, error: 'Webhook not found or failed to update', code: 'WEBHOOK_NOT_FOUND' }); + return; + } + + const response: ApiResponse = { success: true, data: webhook }; + res.json(response); + } catch (error) { + console.error('Error updating webhook:', error); + res.status(500).json({ success: false, error: 'Failed to update webhook', code: 'WEBHOOK_UPDATE_ERROR' }); + return; + } +}); + +/** + * DELETE /api/webhooks/:webhookId + * Delete a webhook + */ +router.delete('/:webhookId', async (req, res) => { + try { + const { webhookId } = req.params; + const success = await webhookService.deleteWebhook(webhookId); + + if (!success) { + res.status(404).json({ success: false, error: 'Webhook not found or failed to delete', code: 'WEBHOOK_DELETE_FAILED' }); + return; + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting webhook:', error); + res.status(500).json({ success: false, error: 'Failed to delete webhook', code: 'WEBHOOK_DELETE_ERROR' }); + return; + } +}); + +export default router; diff --git a/src/api/server.ts b/src/api/server.ts index 987ed47..30d63d6 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -4,6 +4,7 @@ import { createServer } from 'http'; import { Server as SocketIOServer } from 'socket.io'; import { config } from '../config/index.js'; import { authMiddleware, errorHandler, notFoundHandler } from './middleware/auth.js'; +import { rateLimiter } from './middleware/rateLimit.js'; import { setupWebSocketEvents } from './websocket/events.js'; import { setSocketServer } from '../discord/events/index.js'; import type { @@ -19,11 +20,38 @@ import membersRouter from './routes/members.js'; import messagesRouter from './routes/messages.js'; import channelsRouter from './routes/channels.js'; import rolesRouter from './routes/roles.js'; +import stickersRouter from './routes/stickers.js'; +import scheduledEventsRouter from './routes/scheduled-events.js'; +import autoModRouter from './routes/automod.js'; +import stageInstancesRouter from './routes/stage-instances.js'; +import invitesRouter from './routes/invites.js'; +import webhooksRouter from './routes/webhooks.js'; +import emojisRouter from './routes/emojis.js'; +import { pluginManager } from '../plugins/manager.js'; +import type { Application } from 'express'; +import type { Server as HttpServer } from 'http'; + +/** + * API Server instance type + */ +export interface ApiServerInstance { + app: Application; + httpServer: HttpServer; + io: SocketIOServer; +} + +// Store server instance for reuse +let serverInstance: ApiServerInstance | null = null; /** * Create and configure the API server */ -export function createApiServer() { +export function createApiServer(): ApiServerInstance { + // Return existing instance if already created + if (serverInstance) { + return serverInstance; + } + const app = express(); const httpServer = createServer(app); @@ -47,6 +75,9 @@ export function createApiServer() { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); + // Apply rate limiting globally (before auth) + app.use('/api', rateLimiter()); + // Apply authentication to all /api routes app.use('/api', authMiddleware); @@ -56,6 +87,16 @@ export function createApiServer() { app.use('/api/guilds/:guildId/roles', rolesRouter); app.use('/api/channels/:channelId/messages', messagesRouter); app.use('/api/channels', channelsRouter); + app.use('/api/guilds/:guildId/stickers', stickersRouter); + app.use('/api/guilds/:guildId/scheduled-events', scheduledEventsRouter); + app.use('/api/guilds/:guildId/auto-moderation', autoModRouter); + app.use('/api/guilds/:guildId/emojis', emojisRouter); + app.use('/api/stage-instances', stageInstancesRouter); + app.use('/api/invites', invitesRouter); + app.use('/api/webhooks', webhooksRouter); + + // Mount plugin routes (plugins inherit auth middleware from /api) + app.use('/api/plugins', pluginManager.getPluginRouter()); // Error handlers app.use(notFoundHandler); @@ -67,7 +108,10 @@ export function createApiServer() { // Connect socket server to Discord event broadcaster setSocketServer(io); - return { app, httpServer, io }; + // Store instance + serverInstance = { app, httpServer, io }; + + return serverInstance; } /** @@ -80,6 +124,7 @@ export function startApiServer(): Promise { httpServer.listen(config.api.port, () => { console.log(`🌐 API server listening on port ${config.api.port}`); console.log(` REST API: http://localhost:${config.api.port}/api`); + console.log(` Plugin API: http://localhost:${config.api.port}/api/plugins`); console.log(` WebSocket: ws://localhost:${config.api.port}`); console.log(` Health check: http://localhost:${config.api.port}/health`); resolve(); diff --git a/src/config/index.ts b/src/config/index.ts index 34b6943..b05f6f0 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,13 +3,34 @@ import dotenv from 'dotenv'; dotenv.config(); +// Schema for individual API keys with scopes +const apiKeySchema = z.object({ + id: z.string(), + name: z.string(), + key: z.string(), + scopes: z.array(z.string()), + createdAt: z.coerce.date().optional(), +}); + const configSchema = z.object({ discord: z.object({ token: z.string().min(1, 'Discord token is required'), }), api: z.object({ port: z.number().int().positive().default(3000), + // Legacy single key (still supported for backwards compatibility) apiKey: z.string().min(1, 'API key is required'), + // New: Multiple API keys with scopes + apiKeys: z.array(apiKeySchema).default([]), + }), + plugins: z.object({ + enabled: z.boolean().default(true), + directory: z.string().default('plugins'), + }), + rateLimit: z.object({ + enabled: z.boolean().default(true), + windowMs: z.number().default(60000), // 1 minute + maxRequests: z.number().default(100), // 100 requests per minute }), debug: z.boolean().default(false), }); @@ -24,6 +45,17 @@ function loadConfig(): Config { api: { port: parseInt(process.env['PORT'] ?? '3000', 10), apiKey: process.env['API_KEY'] ?? '', + // API keys can be loaded from a JSON file or environment variable + apiKeys: parseApiKeys(process.env['API_KEYS']), + }, + plugins: { + enabled: process.env['PLUGINS_ENABLED'] !== 'false', + directory: process.env['PLUGINS_DIR'] ?? 'plugins', + }, + rateLimit: { + enabled: process.env['RATE_LIMIT_ENABLED'] !== 'false', + windowMs: parseInt(process.env['RATE_LIMIT_WINDOW_MS'] ?? '60000', 10), + maxRequests: parseInt(process.env['RATE_LIMIT_MAX'] ?? '100', 10), }, debug: process.env['DEBUG'] === 'true', }; @@ -41,4 +73,36 @@ function loadConfig(): Config { return result.data; } +/** + * Parse API_KEYS environment variable (JSON array) with schema validation + */ +function parseApiKeys(envVar: string | undefined): z.infer[] { + if (!envVar) return []; + + // Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(envVar); + } catch { + console.warn('⚠️ Failed to parse API_KEYS env var as JSON. Using empty array.'); + return []; + } + + // Validate against schema + const apiKeysArraySchema = z.array(apiKeySchema); + const result = apiKeysArraySchema.safeParse(parsed); + + if (!result.success) { + console.warn('⚠️ API_KEYS validation failed:'); + result.error.issues.forEach((issue) => { + console.warn(` - ${issue.path.join('.')}: ${issue.message}`); + }); + console.warn('Using empty array for API keys.'); + return []; + } + + return result.data; +} + export const config = loadConfig(); + diff --git a/src/discord/events/index.ts b/src/discord/events/index.ts index 8924c74..17ec196 100644 --- a/src/discord/events/index.ts +++ b/src/discord/events/index.ts @@ -41,6 +41,8 @@ export function setSocketServer( io = server; } +import { pluginManager } from '../../plugins/manager.js'; + /** * Broadcast a Discord event to subscribed clients */ @@ -56,6 +58,9 @@ function broadcastEvent(payload: DiscordEventPayload): void { io.emit('discord', payload); } + // Notify plugins of this event + void pluginManager.emit(payload.event, payload.data); + if (config.debug) { console.log(`📤 Broadcast event: ${payload.event}${guildId ? ` (guild: ${guildId})` : ''}`); } @@ -183,7 +188,7 @@ export function registerDiscordEvents(): void { const poll = pollAnswer.poll; const message = poll?.message; if (!message) return; // Skip if message is not available - + broadcastEvent({ event: 'messagePollVoteAdd', guildId: message.guildId ?? null, @@ -201,7 +206,7 @@ export function registerDiscordEvents(): void { const poll = pollAnswer.poll; const message = poll?.message; if (!message) return; // Skip if message is not available - + broadcastEvent({ event: 'messagePollVoteRemove', guildId: message.guildId ?? null, diff --git a/src/discord/services/automod.service.ts b/src/discord/services/automod.service.ts new file mode 100644 index 0000000..98821f3 --- /dev/null +++ b/src/discord/services/automod.service.ts @@ -0,0 +1,87 @@ +import { discordClient } from '../client.js'; +import { serializeAutoModRule } from '../serializers.js'; +import type { SerializedAutoModRule } from '../../types/discord.types.js'; +import type { AutoModerationRuleCreateOptions, AutoModerationRuleEditOptions } from 'discord.js'; + +export class AutoModService { + /** + * Get all auto-moderation rules in a guild + */ + async getAutoModRules(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + try { + const rules = await guild.autoModerationRules.fetch(); + return rules.map(serializeAutoModRule); + } catch (error) { + console.error(`Failed to fetch auto-moderation rules for guild ${guildId}:`, error); + return []; + } + } + + /** + * Get a specific auto-moderation rule + */ + async getAutoModRule(guildId: string, ruleId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const rule = await guild.autoModerationRules.fetch(ruleId); + return serializeAutoModRule(rule); + } catch { + return null; + } + } + + /** + * Create a new auto-moderation rule + */ + async createAutoModRule(guildId: string, data: AutoModerationRuleCreateOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const rule = await guild.autoModerationRules.create(data); + return serializeAutoModRule(rule); + } catch (error) { + console.error(`Failed to create auto-moderation rule in guild ${guildId}:`, error); + return null; + } + } + + /** + * Edit an auto-moderation rule + */ + async editAutoModRule(guildId: string, ruleId: string, data: AutoModerationRuleEditOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const rule = await guild.autoModerationRules.fetch(ruleId); + const updated = await rule.edit(data); + return serializeAutoModRule(updated); + } catch { + return null; + } + } + + /** + * Delete an auto-moderation rule + */ + async deleteAutoModRule(guildId: string, ruleId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return false; + + try { + const rule = await guild.autoModerationRules.fetch(ruleId); + await rule.delete(); + return true; + } catch { + return false; + } + } +} + +export const autoModService = new AutoModService(); diff --git a/src/discord/services/emoji.service.ts b/src/discord/services/emoji.service.ts new file mode 100644 index 0000000..12f2419 --- /dev/null +++ b/src/discord/services/emoji.service.ts @@ -0,0 +1,87 @@ +import { discordClient } from '../client.js'; +import { serializeGuildEmoji } from '../serializers.js'; +import type { SerializedEmoji } from '../../types/discord.types.js'; +import type { GuildEmojiCreateOptions, GuildEmojiEditOptions } from 'discord.js'; + +export class EmojiService { + /** + * Get all emojis in a guild + */ + async getGuildEmojis(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + try { + const emojis = await guild.emojis.fetch(); + return emojis.map(serializeGuildEmoji); + } catch (error) { + console.error(`Failed to fetch emojis for guild ${guildId}:`, error); + return []; + } + } + + /** + * Get a specific emoji + */ + async getEmoji(guildId: string, emojiId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const emoji = await guild.emojis.fetch(emojiId); + return serializeGuildEmoji(emoji); + } catch { + return null; + } + } + + /** + * Create a new emoji + */ + async createEmoji(guildId: string, data: GuildEmojiCreateOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const emoji = await guild.emojis.create(data); + return serializeGuildEmoji(emoji); + } catch (error) { + console.error(`Failed to create emoji in guild ${guildId}:`, error); + return null; + } + } + + /** + * Edit an emoji + */ + async editEmoji(guildId: string, emojiId: string, data: GuildEmojiEditOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const emoji = await guild.emojis.fetch(emojiId); + const updated = await emoji.edit(data); + return serializeGuildEmoji(updated); + } catch { + return null; + } + } + + /** + * Delete an emoji + */ + async deleteEmoji(guildId: string, emojiId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return false; + + try { + const emoji = await guild.emojis.fetch(emojiId); + await emoji.delete(); + return true; + } catch { + return false; + } + } +} + +export const emojiService = new EmojiService(); diff --git a/src/discord/services/index.ts b/src/discord/services/index.ts index 3f66f24..cbcca2f 100644 --- a/src/discord/services/index.ts +++ b/src/discord/services/index.ts @@ -3,3 +3,10 @@ export { memberService } from './member.service.js'; export { messageService } from './message.service.js'; export { channelService } from './channel.service.js'; export { roleService } from './role.service.js'; +export { stickerService } from './sticker.service.js'; +export { scheduledEventService } from './scheduled-event.service.js'; +export { autoModService } from './automod.service.js'; +export { stageInstanceService } from './stage-instance.service.js'; +export { inviteService } from './invite.service.js'; +export { webhookService } from './webhook.service.js'; +export { emojiService } from './emoji.service.js'; diff --git a/src/discord/services/invite.service.ts b/src/discord/services/invite.service.ts new file mode 100644 index 0000000..32e52c8 --- /dev/null +++ b/src/discord/services/invite.service.ts @@ -0,0 +1,74 @@ +import { discordClient } from '../client.js'; +import { serializeInvite } from '../serializers.js'; +import type { SerializedInvite } from '../../types/discord.types.js'; +import type { InviteCreateOptions } from 'discord.js'; + +export class InviteService { + /** + * Get an invite by code + */ + async getInvite(code: string): Promise { + try { + const invite = await discordClient.fetchInvite(code); + return serializeInvite(invite); + } catch { + return null; + } + } + + /** + * Delete an invite + */ + async deleteInvite(code: string): Promise { + try { + const invite = await discordClient.fetchInvite(code); + await invite.delete(); + return true; + } catch { + return false; + } + } + + /** + * Get all invites in a guild + */ + async getGuildInvites(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + const invites = await guild.invites.fetch(); + return invites.map(serializeInvite); + } + + /** + * Get all invites for a channel + */ + async getChannelInvites(channelId: string): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !channel.isTextBased() || !('createInvite' in channel)) return []; + + try { + const invites = await channel.fetchInvites(); + return invites.map(serializeInvite); + } catch { + return []; + } + } + + /** + * Create an invite for a channel + */ + async createChannelInvite(channelId: string, data: InviteCreateOptions): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !('createInvite' in channel)) return null; + + try { + const invite = await channel.createInvite(data); + return serializeInvite(invite); + } catch { + return null; + } + } +} + +export const inviteService = new InviteService(); diff --git a/src/discord/services/scheduled-event.service.ts b/src/discord/services/scheduled-event.service.ts new file mode 100644 index 0000000..a141af6 --- /dev/null +++ b/src/discord/services/scheduled-event.service.ts @@ -0,0 +1,103 @@ +import { discordClient } from '../client.js'; +import { serializeScheduledEvent, serializeUser } from '../serializers.js'; +import type { SerializedScheduledEvent, SerializedUser } from '../../types/discord.types.js'; +import type { GuildScheduledEventCreateOptions, GuildScheduledEventEditOptions, GuildScheduledEventStatus } from 'discord.js'; + +export class ScheduledEventService { + /** + * Get all scheduled events in a guild + */ + async getGuildEvents(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + try { + const events = await guild.scheduledEvents.fetch(); + return events.map(serializeScheduledEvent); + } catch (error) { + console.error(`Failed to fetch scheduled events for guild ${guildId}:`, error); + return []; + } + } + + /** + * Get a specific scheduled event + */ + async getEvent(guildId: string, eventId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const event = await guild.scheduledEvents.fetch(eventId); + return serializeScheduledEvent(event); + } catch { + return null; + } + } + + /** + * Create a new scheduled event + */ + async createEvent(guildId: string, data: GuildScheduledEventCreateOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const event = await guild.scheduledEvents.create(data); + return serializeScheduledEvent(event); + } catch (error) { + console.error(`Failed to create scheduled event in guild ${guildId}:`, error); + return null; + } + } + + /** + * Edit a scheduled event + */ + async editEvent(guildId: string, eventId: string, data: GuildScheduledEventEditOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const event = await guild.scheduledEvents.fetch(eventId); + const updated = await event.edit(data); + return serializeScheduledEvent(updated); + } catch { + return null; + } + } + + /** + * Delete a scheduled event + */ + async deleteEvent(guildId: string, eventId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return false; + + try { + const event = await guild.scheduledEvents.fetch(eventId); + await event.delete(); + return true; + } catch { + return false; + } + } + + /** + * Get users subscribed to an event + */ + async getEventUsers(guildId: string, eventId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + try { + const event = await guild.scheduledEvents.fetch(eventId); + const users = await event.fetchSubscribers(); + return users.map(u => serializeUser(u.user)); + } catch { + return []; + } + } +} + +export const scheduledEventService = new ScheduledEventService(); diff --git a/src/discord/services/stage-instance.service.ts b/src/discord/services/stage-instance.service.ts new file mode 100644 index 0000000..105dc97 --- /dev/null +++ b/src/discord/services/stage-instance.service.ts @@ -0,0 +1,82 @@ +import { ChannelType } from 'discord.js'; +import type { StageInstanceCreateOptions, StageInstanceEditOptions } from 'discord.js'; +import { discordClient } from '../client.js'; +import { serializeStageInstance } from '../serializers.js'; +import type { SerializedStageInstance } from '../../types/discord.types.js'; + +export class StageInstanceService { + /** + * Get stage instance for a channel + */ + async getStageInstance(channelId: string): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !channel.isVoiceBased() || !channel.guild) return null; + + try { + const stageInstance = await channel.guild.stageInstances.fetch(channelId); + return stageInstance ? serializeStageInstance(stageInstance) : null; + } catch { + return null; + } + } + + /** + * Create a new stage instance + */ + async createStageInstance(channelId: string, topic: string, options?: Omit): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !channel.isVoiceBased() || !channel.guild) return null; + + // Only stage channels can have stage instances + if (channel.type !== ChannelType.GuildStageVoice) return null; + + try { + const stageInstance = await channel.guild.stageInstances.create(channel, { + topic, + ...options + }); + return serializeStageInstance(stageInstance); + } catch (error) { + console.error(`Failed to create stage instance for channel ${channelId}:`, error); + return null; + } + } + + /** + * Edit a stage instance + */ + async editStageInstance(channelId: string, data: StageInstanceEditOptions): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !channel.isVoiceBased() || !channel.guild) return null; + + try { + const stageInstance = await channel.guild.stageInstances.fetch(channelId); + if (!stageInstance) return null; + + const updated = await stageInstance.edit(data); + return serializeStageInstance(updated); + } catch { + return null; + } + } + + /** + * Delete a stage instance + */ + async deleteStageInstance(channelId: string): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !channel.isVoiceBased() || !channel.guild) return false; + + try { + const stageInstance = await channel.guild.stageInstances.fetch(channelId); + if (!stageInstance) return false; + + await stageInstance.delete(); + return true; + } catch { + return false; + } + } +} + +export const stageInstanceService = new StageInstanceService(); diff --git a/src/discord/services/sticker.service.ts b/src/discord/services/sticker.service.ts new file mode 100644 index 0000000..e515493 --- /dev/null +++ b/src/discord/services/sticker.service.ts @@ -0,0 +1,77 @@ +import { discordClient } from '../client.js'; +import { serializeSticker } from '../serializers.js'; +import type { SerializedSticker } from '../../types/discord.types.js'; +import type { GuildStickerCreateOptions, GuildStickerEditOptions } from 'discord.js'; + +export class StickerService { + /** + * Get all stickers in a guild + */ + async getGuildStickers(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + const stickers = await guild.stickers.fetch(); + return stickers.map(serializeSticker); + } + + /** + * Get a specific sticker + */ + async getSticker(guildId: string, stickerId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const sticker = await guild.stickers.fetch(stickerId); + return serializeSticker(sticker); + } catch { + return null; + } + } + + /** + * Create a new sticker + */ + async createSticker(guildId: string, data: GuildStickerCreateOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + const sticker = await guild.stickers.create(data); + return serializeSticker(sticker); + } + + /** + * Edit a sticker + */ + async editSticker(guildId: string, stickerId: string, data: GuildStickerEditOptions): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return null; + + try { + const sticker = await guild.stickers.fetch(stickerId); + const updated = await sticker.edit(data); + return serializeSticker(updated); + } catch { + return null; + } + } + + /** + * Delete a sticker + */ + async deleteSticker(guildId: string, stickerId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return false; + + try { + const sticker = await guild.stickers.fetch(stickerId); + await sticker.delete(); + return true; + } catch { + return false; + } + } +} + +export const stickerService = new StickerService(); diff --git a/src/discord/services/webhook.service.ts b/src/discord/services/webhook.service.ts new file mode 100644 index 0000000..a1e31dd --- /dev/null +++ b/src/discord/services/webhook.service.ts @@ -0,0 +1,101 @@ +import { discordClient } from '../client.js'; +import { serializeWebhook } from '../serializers.js'; +import type { SerializedWebhook } from '../../types/discord.types.js'; +import type { ChannelWebhookCreateOptions } from 'discord.js'; + +export class WebhookService { + /** + * Get all webhooks in a channel + */ + async getChannelWebhooks(channelId: string): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !('fetchWebhooks' in channel)) return []; + + try { + const webhooks = await channel.fetchWebhooks(); + return webhooks.map(serializeWebhook); + } catch { + return []; + } + } + + /** + * Get all webhooks in a guild + */ + async getGuildWebhooks(guildId: string): Promise { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) return []; + + try { + const webhooks = await guild.fetchWebhooks(); + return webhooks.map(serializeWebhook); + } catch { + return []; + } + } + + /** + * Get a specific webhook + */ + async getWebhook(webhookId: string): Promise { + try { + const webhook = await discordClient.fetchWebhook(webhookId); + return serializeWebhook(webhook); + } catch { + return null; + } + } + + /** + * Create a webhook + */ + async createWebhook(channelId: string, data: ChannelWebhookCreateOptions): Promise { + const channel = discordClient.channels.cache.get(channelId); + if (!channel || !('createWebhook' in channel)) return null; + + try { + const webhook = await channel.createWebhook(data); + return serializeWebhook(webhook); + } catch { + return null; + } + } + + /** + * Edit a webhook + */ + async editWebhook(webhookId: string, data: { name?: string; avatar?: string | null; channelId?: string }): Promise { + try { + const webhook = await discordClient.fetchWebhook(webhookId); + + // Extract channelId and build the edit payload with proper discord.js property names + const { channelId, ...rest } = data; + const editPayload: { name?: string; avatar?: string | null; channel?: string } = { ...rest }; + + // discord.js expects 'channel' not 'channelId' for moving webhooks + if (channelId) { + editPayload.channel = channelId; + } + + const updated = await webhook.edit(editPayload); + return serializeWebhook(updated); + } catch { + return null; + } + } + + /** + * Delete a webhook + */ + async deleteWebhook(webhookId: string): Promise { + try { + const webhook = await discordClient.fetchWebhook(webhookId); + await webhook.delete(); + return true; + } catch { + return false; + } + } +} + +export const webhookService = new WebhookService(); diff --git a/src/index.ts b/src/index.ts index b1b48f6..61c7724 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ -import { loginDiscord, waitForReady } from './discord/client.js'; +import { loginDiscord, waitForReady, discordClient } from './discord/client.js'; import { registerDiscordEvents } from './discord/events/index.js'; -import { startApiServer } from './api/server.js'; +import { createApiServer, startApiServer } from './api/server.js'; +import { pluginManager } from './plugins/manager.js'; +import { config } from './config/index.js'; async function main(): Promise { console.log('🚀 Starting Holo Bridge...\n'); @@ -16,6 +18,17 @@ async function main(): Promise { registerDiscordEvents(); console.log(''); + // Create API server (needed for plugin context) + const { io, app } = createApiServer(); + + // Initialize and load plugins + if (config.plugins.enabled) { + console.log('🔌 Initializing plugin system...'); + pluginManager.setContext(discordClient, io, config, app); + await pluginManager.loadPlugins(); + console.log(''); + } + // Start API server await startApiServer(); console.log(''); @@ -28,15 +41,21 @@ async function main(): Promise { } // Handle graceful shutdown -process.on('SIGINT', () => { +async function shutdown(): Promise { console.log('\n🛑 Shutting down...'); - process.exit(0); -}); -process.on('SIGTERM', () => { - console.log('\n🛑 Shutting down...'); + // Unload plugins gracefully + if (pluginManager.count > 0) { + console.log('🔌 Unloading plugins...'); + await pluginManager.unloadAll(); + } + process.exit(0); -}); +} + +process.on('SIGINT', () => void shutdown()); +process.on('SIGTERM', () => void shutdown()); // Start the application main(); + diff --git a/src/plugins/event-bus.ts b/src/plugins/event-bus.ts new file mode 100644 index 0000000..1164b5b --- /dev/null +++ b/src/plugins/event-bus.ts @@ -0,0 +1,209 @@ +import { EventEmitter } from 'events'; +import type { DiscordEventType } from '../types/events.types.js'; + +/** + * Event categories for the plugin event bus + */ +export type PluginEventCategory = 'discord' | 'plugin' | 'custom'; + +/** + * Plugin lifecycle events + */ +export interface PluginLifecycleEvents { + 'plugin:loaded': { name: string; version: string }; + 'plugin:unloaded': { name: string }; + 'plugin:error': { name: string; error: Error }; +} + +/** + * Custom event payload - plugins can emit any data + */ +export type CustomEventPayload = Record; + +/** + * Event subscription returned when subscribing to events + */ +export interface EventSubscription { + unsubscribe: () => void; + eventName: string; +} + +/** + * Typed event bus for inter-plugin communication. + * + * Provides three categories of events: + * - `discord:*` - Discord events forwarded from the gateway + * - `plugin:*` - Plugin lifecycle events (loaded, unloaded, error) + * - `custom:*` - Custom events emitted by plugins + * + * @example + * ```typescript + * // Subscribe to Discord events + * eventBus.on('discord:messageCreate', (data) => { + * console.log('New message:', data.content); + * }); + * + * // Emit custom events + * eventBus.emit('custom:user-warned', { userId: '123', reason: 'spam' }); + * + * // Subscribe to custom events from other plugins + * eventBus.on('custom:user-warned', (data) => { + * console.log(`User ${data.userId} was warned for ${data.reason}`); + * }); + * ``` + */ +export class PluginEventBus extends EventEmitter { + private subscriptions: Map void>> = new Map(); + private debugMode: boolean = false; + + constructor(debug: boolean = false) { + super(); + this.debugMode = debug; + // Increase max listeners to avoid warnings with many plugins + this.setMaxListeners(100); + } + + /** + * Enable or disable debug logging + */ + setDebug(enabled: boolean): void { + this.debugMode = enabled; + } + + /** + * Emit a Discord event to all subscribed plugins + */ + emitDiscord(eventName: DiscordEventType, data: T): boolean { + const fullEventName = `discord:${eventName}`; + if (this.debugMode) { + console.log(`[EventBus] Discord event: ${fullEventName}`); + } + return this.emit(fullEventName, data); + } + + /** + * Emit a plugin lifecycle event + */ + emitPlugin( + eventName: K, + data: PluginLifecycleEvents[K] + ): boolean { + if (this.debugMode) { + console.log(`[EventBus] Plugin event: ${eventName}`, data); + } + return this.emit(eventName, data); + } + + /** + * Emit a custom event that other plugins can listen to + */ + emitCustom(eventName: string, data: T): boolean { + const fullEventName = eventName.startsWith('custom:') ? eventName : `custom:${eventName}`; + if (this.debugMode) { + console.log(`[EventBus] Custom event: ${fullEventName}`); + } + return this.emit(fullEventName, data); + } + + /** + * Subscribe to a Discord event + */ + onDiscord( + eventName: DiscordEventType, + listener: (data: T) => void + ): EventSubscription { + const fullEventName = `discord:${eventName}`; + return this.subscribe(fullEventName, listener as (...args: unknown[]) => void); + } + + /** + * Subscribe to a plugin lifecycle event + */ + onPlugin( + eventName: K, + listener: (data: PluginLifecycleEvents[K]) => void + ): EventSubscription { + return this.subscribe(eventName, listener as (...args: unknown[]) => void); + } + + /** + * Subscribe to a custom event + */ + onCustom( + eventName: string, + listener: (data: T) => void + ): EventSubscription { + const fullEventName = eventName.startsWith('custom:') ? eventName : `custom:${eventName}`; + return this.subscribe(fullEventName, listener as (...args: unknown[]) => void); + } + + /** + * Subscribe to any event and return a subscription object + */ + subscribe(eventName: string, listener: (...args: unknown[]) => void): EventSubscription { + this.on(eventName, listener); + + // Track subscription for cleanup + if (!this.subscriptions.has(eventName)) { + this.subscriptions.set(eventName, new Set()); + } + this.subscriptions.get(eventName)!.add(listener); + + return { + eventName, + unsubscribe: () => { + this.off(eventName, listener); + this.subscriptions.get(eventName)?.delete(listener); + }, + }; + } + + /** + * Subscribe to an event once + */ + subscribeOnce(eventName: string, listener: (...args: unknown[]) => void): EventSubscription { + const wrappedListener = (...args: unknown[]) => { + this.subscriptions.get(eventName)?.delete(wrappedListener); + listener(...args); + }; + + this.once(eventName, wrappedListener); + + if (!this.subscriptions.has(eventName)) { + this.subscriptions.set(eventName, new Set()); + } + this.subscriptions.get(eventName)!.add(wrappedListener); + + return { + eventName, + unsubscribe: () => { + this.off(eventName, wrappedListener); + this.subscriptions.get(eventName)?.delete(wrappedListener); + }, + }; + } + + /** + * Unsubscribe all listeners for a specific plugin + * Called when a plugin is unloaded + */ + unsubscribeAll(subscriptions: EventSubscription[]): void { + for (const sub of subscriptions) { + sub.unsubscribe(); + } + } + + /** + * Get count of listeners for debugging + */ + getListenerCounts(): Record { + const counts: Record = {}; + for (const [eventName, listeners] of this.subscriptions) { + counts[eventName] = listeners.size; + } + return counts; + } +} + +// Singleton instance +export const pluginEventBus = new PluginEventBus(); diff --git a/src/plugins/manager.ts b/src/plugins/manager.ts new file mode 100644 index 0000000..746804e --- /dev/null +++ b/src/plugins/manager.ts @@ -0,0 +1,315 @@ +import { readdir, access, mkdir } from 'fs/promises'; +import { constants } from 'fs'; +import { join, resolve } from 'path'; +import { pathToFileURL } from 'url'; +import { Router } from 'express'; +import type { Application } from 'express'; +import type { Client } from 'discord.js'; +import type { Server as SocketIOServer } from 'socket.io'; +import type { Config } from '../config/index.js'; +import type { + HoloPlugin, + PluginContext, + PluginExport, + PluginMetadata, +} from '../types/plugin.types.js'; +import type { + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, +} from '../types/events.types.js'; +import { pluginEventBus, type EventSubscription } from './event-bus.js'; +import { createLogger, createEventHelpers, withErrorHandler, type PluginRouter } from './sdk.js'; + +/** + * Internal plugin record with runtime state + */ +interface LoadedPlugin { + plugin: HoloPlugin; + router: Router | null; + eventSubscriptions: EventSubscription[]; +} + +/** + * Manages the lifecycle of HoloBridge plugins. + */ +export class PluginManager { + private plugins: Map = new Map(); + private context: PluginContext | null = null; + private pluginsDir: string; + private app: Application | null = null; + private pluginRouter: Router; + + constructor(pluginsDir?: string) { + this.pluginsDir = pluginsDir ?? resolve(process.cwd(), 'plugins'); + this.pluginRouter = Router(); + } + + /** + * Get the main plugin router (mounted at /api/plugins) + */ + getPluginRouter(): Router { + return this.pluginRouter; + } + + /** + * Initialize the plugin context with core services. + */ + setContext( + client: Client, + io: SocketIOServer, + config: Config, + app: Application + ): void { + this.app = app; + + this.context = { + client, + io, + config, + app, + eventBus: pluginEventBus, + log: (message: string) => console.log(`[Plugin] ${message}`), + logger: createLogger('Plugin', config.debug), + getPlugin: (name: string) => this.getPluginMetadata(name), + listPlugins: () => this.loadedPlugins, + }; + + // Set debug mode on event bus + pluginEventBus.setDebug(config.debug); + } + + /** + * Get metadata for a loaded plugin + */ + getPluginMetadata(name: string): PluginMetadata | undefined { + const loaded = this.plugins.get(name); + return loaded?.plugin.metadata; + } + + /** + * Load all plugins from the plugins directory. + */ + async loadPlugins(): Promise { + // Ensure plugins directory exists + try { + await access(this.pluginsDir, constants.R_OK); + } catch { + console.log(`📁 Creating plugins directory: ${this.pluginsDir}`); + await mkdir(this.pluginsDir, { recursive: true }); + return; + } + + // Read plugin files + const entries = await readdir(this.pluginsDir, { withFileTypes: true }); + const pluginFiles = entries.filter( + (e) => e.isFile() && (e.name.endsWith('.js') || e.name.endsWith('.mjs')) + ); + + if (pluginFiles.length === 0) { + console.log('📦 No plugins found in plugins/ directory'); + return; + } + + console.log(`📦 Loading ${pluginFiles.length} plugin(s)...`); + + for (const file of pluginFiles) { + try { + await this.loadPlugin(join(this.pluginsDir, file.name)); + } catch (error) { + console.error(`❌ Failed to load plugin ${file.name}:`, error); + } + } + } + + /** + * Load a single plugin from a file path. + */ + private async loadPlugin(filePath: string): Promise { + if (!this.context) { + throw new Error('Plugin context not initialized. Call setContext() first.'); + } + + // Dynamic import using file URL (required for ESM) + const fileUrl = pathToFileURL(filePath).href; + const module = (await import(fileUrl)) as PluginExport; + + // Support both default export and direct export + const plugin: HoloPlugin = 'default' in module ? module.default : module; + + if (!plugin.metadata?.name || !plugin.metadata?.version) { + throw new Error(`Plugin at ${filePath} is missing required metadata (name, version)`); + } + + const { name, version } = plugin.metadata; + + // Check for duplicate + if (this.plugins.has(name)) { + throw new Error(`Plugin "${name}" is already loaded`); + } + + // Create plugin-specific context with logger + const pluginContext: PluginContext = { + ...this.context, + log: (message: string) => console.log(`[${name}] ${message}`), + logger: createLogger(name, this.context.config.debug), + }; + + // Set up event subscriptions + let eventSubscriptions: EventSubscription[] = []; + if (plugin.events) { + const helpers = createEventHelpers(pluginEventBus); + try { + eventSubscriptions = plugin.events(helpers, pluginContext) || []; + } catch (error) { + console.error(`❌ Plugin "${name}" failed to set up events:`, error); + } + } + + // Set up routes + let pluginSubRouter: Router | null = null; + if (plugin.routes) { + pluginSubRouter = Router(); + + // Create wrapper that adds error handling + const wrappedRouter: PluginRouter = { + get: (path, ...handlers) => { + pluginSubRouter!.get(path, ...handlers.map(h => withErrorHandler(h, name))); + }, + post: (path, ...handlers) => { + pluginSubRouter!.post(path, ...handlers.map(h => withErrorHandler(h, name))); + }, + put: (path, ...handlers) => { + pluginSubRouter!.put(path, ...handlers.map(h => withErrorHandler(h, name))); + }, + patch: (path, ...handlers) => { + pluginSubRouter!.patch(path, ...handlers.map(h => withErrorHandler(h, name))); + }, + delete: (path, ...handlers) => { + pluginSubRouter!.delete(path, ...handlers.map(h => withErrorHandler(h, name))); + }, + use: (...args: unknown[]) => { + // Helper to wrap a single handler function + const wrapHandler = (handler: unknown): unknown => { + if (typeof handler === 'function') { + return withErrorHandler(handler as Parameters[0], name); + } + if (Array.isArray(handler)) { + return handler.map(wrapHandler); + } + return handler; + }; + + // Determine if first argument is a path + const firstArg = args[0]; + const isPath = typeof firstArg === 'string' || firstArg instanceof RegExp; + + let normalizedArgs: unknown[]; + if (isPath) { + // First arg is path, rest are handlers + normalizedArgs = [firstArg, ...args.slice(1).map(wrapHandler)]; + } else { + // All args are handlers (or arrays of handlers) + normalizedArgs = args.map(wrapHandler); + } + + pluginSubRouter!.use(...normalizedArgs as Parameters); + }, + }; + + try { + plugin.routes(wrappedRouter, pluginContext); + // Mount plugin routes under /api/plugins/{plugin-name}/ + this.pluginRouter.use(`/${name}`, pluginSubRouter); + console.log(` 🛤️ Routes registered at /api/plugins/${name}/`); + } catch (error) { + console.error(`❌ Plugin "${name}" failed to register routes:`, error); + pluginSubRouter = null; + } + } + + // Call onLoad lifecycle hook + if (plugin.onLoad) { + await plugin.onLoad(pluginContext); + } + + // Store plugin state + this.plugins.set(name, { + plugin, + router: pluginSubRouter, + eventSubscriptions, + }); + + // Emit plugin loaded event + pluginEventBus.emitPlugin('plugin:loaded', { name, version }); + + console.log(` ✅ Loaded: ${name} v${version}`); + } + + /** + * Emit an event to all loaded plugins. + */ + async emit(eventName: string, data: unknown): Promise { + // Emit to event bus for new-style subscriptions + pluginEventBus.emitDiscord(eventName as never, data); + + // Also call legacy onEvent handlers + for (const [name, { plugin }] of this.plugins) { + if (plugin.onEvent) { + try { + await plugin.onEvent(eventName, data); + } catch (error) { + console.error(`❌ Plugin "${name}" error on event "${eventName}":`, error); + pluginEventBus.emitPlugin('plugin:error', { + name, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + } + } + + /** + * Unload all plugins (call onUnload handlers). + */ + async unloadAll(): Promise { + for (const [name, { plugin, eventSubscriptions }] of this.plugins) { + // Unsubscribe from all events + for (const sub of eventSubscriptions) { + sub.unsubscribe(); + } + + // Call onUnload handler + if (plugin.onUnload) { + try { + await plugin.onUnload(); + console.log(` 🔌 Unloaded: ${name}`); + } catch (error) { + console.error(`❌ Failed to unload plugin "${name}":`, error); + } + } + + // Emit plugin unloaded event + pluginEventBus.emitPlugin('plugin:unloaded', { name }); + } + this.plugins.clear(); + } + + /** + * Get the count of loaded plugins. + */ + get count(): number { + return this.plugins.size; + } + + /** + * Get list of loaded plugin names. + */ + get loadedPlugins(): string[] { + return Array.from(this.plugins.keys()); + } +} + +// Singleton instance +export const pluginManager = new PluginManager(); diff --git a/src/plugins/sdk.ts b/src/plugins/sdk.ts new file mode 100644 index 0000000..ebb237f --- /dev/null +++ b/src/plugins/sdk.ts @@ -0,0 +1,277 @@ +/** + * HoloBridge Plugin SDK + * + * This module provides utilities for creating HoloBridge plugins with + * type-safe event handling, REST endpoints, and inter-plugin communication. + * + * @example + * ```typescript + * import { definePlugin, PluginContext } from 'holobridge/sdk'; + * + * export default definePlugin({ + * metadata: { + * name: 'my-plugin', + * version: '1.0.0', + * }, + * routes: (router) => { + * router.get('/status', (req, res) => { + * res.json({ status: 'ok' }); + * }); + * }, + * onLoad: (ctx) => { + * ctx.logger.info('Plugin loaded!'); + * }, + * }); + * ``` + */ + +import type { Request, Response, NextFunction, Router } from 'express'; +import type { PluginEventBus, EventSubscription, CustomEventPayload } from './event-bus.js'; +import type { DiscordEventType } from '../types/events.types.js'; + +// Re-export types for convenience +export type { EventSubscription, CustomEventPayload } from './event-bus.js'; +export type { PluginContext, PluginMetadata, HoloPlugin } from '../types/plugin.types.js'; +export type { DiscordEventType } from '../types/events.types.js'; + +/** + * Route handler type + */ +export type RouteHandler = ( + req: Request, + res: Response, + next: NextFunction +) => void | Promise; + +/** + * Plugin router interface for registering REST endpoints + */ +export interface PluginRouter { + /** Register a GET endpoint */ + get(path: string, ...handlers: RouteHandler[]): void; + /** Register a POST endpoint */ + post(path: string, ...handlers: RouteHandler[]): void; + /** Register a PUT endpoint */ + put(path: string, ...handlers: RouteHandler[]): void; + /** Register a PATCH endpoint */ + patch(path: string, ...handlers: RouteHandler[]): void; + /** Register a DELETE endpoint */ + delete(path: string, ...handlers: RouteHandler[]): void; + /** Use middleware */ + use(...handlers: RouteHandler[]): void; +} + +/** + * Enhanced logger with plugin context + */ +export interface PluginLogger { + /** Log an info message */ + info(message: string, ...args: unknown[]): void; + /** Log a warning message */ + warn(message: string, ...args: unknown[]): void; + /** Log an error message */ + error(message: string, ...args: unknown[]): void; + /** Log a debug message (only in debug mode) */ + debug(message: string, ...args: unknown[]): void; +} + +/** + * Create a logger instance for a plugin + */ +export function createLogger(pluginName: string, debug: boolean = false): PluginLogger { + const prefix = `[${pluginName}]`; + return { + info: (message: string, ...args: unknown[]) => { + console.log(`${prefix} ${message}`, ...args); + }, + warn: (message: string, ...args: unknown[]) => { + console.warn(`${prefix} ⚠️ ${message}`, ...args); + }, + error: (message: string, ...args: unknown[]) => { + console.error(`${prefix} ❌ ${message}`, ...args); + }, + debug: (message: string, ...args: unknown[]) => { + if (debug) { + console.log(`${prefix} 🔍 ${message}`, ...args); + } + }, + }; +} + +/** + * Wrap a route handler with error handling + */ +export function withErrorHandler( + handler: RouteHandler, + pluginName: string +): RouteHandler { + return async (req: Request, res: Response, next: NextFunction) => { + try { + await handler(req, res, next); + } catch (error) { + console.error(`[${pluginName}] Route error:`, error); + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: 'Internal plugin error', + code: 'PLUGIN_ERROR', + plugin: pluginName, + }); + } + } + }; +} + +/** + * Create a type-safe event listener helper + */ +export function createEventHelpers(eventBus: PluginEventBus) { + return { + /** + * Subscribe to Discord events + */ + onDiscord( + event: DiscordEventType, + handler: (data: T) => void | Promise + ): EventSubscription { + return eventBus.onDiscord(event, handler); + }, + + /** + * Subscribe to custom events from other plugins + */ + onCustom( + event: string, + handler: (data: T) => void | Promise + ): EventSubscription { + return eventBus.onCustom(event, handler); + }, + + /** + * Emit a custom event for other plugins + */ + emit(event: string, data: T): void { + eventBus.emitCustom(event, data); + }, + + /** + * Subscribe to plugin lifecycle events + */ + onPluginLoaded(handler: (data: { name: string; version: string }) => void): EventSubscription { + return eventBus.onPlugin('plugin:loaded', handler); + }, + + onPluginUnloaded(handler: (data: { name: string }) => void): EventSubscription { + return eventBus.onPlugin('plugin:unloaded', handler); + }, + }; +} + +/** + * Plugin definition options for definePlugin helper + */ +export interface PluginDefinition { + /** Plugin metadata */ + metadata: { + name: string; + version: string; + author?: string; + description?: string; + }; + + /** Register REST API routes */ + routes?: (router: PluginRouter, ctx: import('../types/plugin.types.js').PluginContext) => void; + + /** Setup event subscriptions */ + events?: ( + helpers: ReturnType, + ctx: import('../types/plugin.types.js').PluginContext + ) => EventSubscription[]; + + /** Called when plugin is loaded */ + onLoad?: (ctx: import('../types/plugin.types.js').PluginContext) => void | Promise; + + /** Called when plugin is unloaded */ + onUnload?: () => void | Promise; + + /** Called for every Discord event (legacy support) */ + onEvent?: (eventName: string, data: unknown) => void | Promise; +} + +/** + * Define a plugin with enhanced type safety and features. + * + * This is the recommended way to create HoloBridge plugins. + * + * @example + * ```typescript + * export default definePlugin({ + * metadata: { name: 'my-plugin', version: '1.0.0' }, + * + * routes: (router) => { + * router.get('/hello', (req, res) => { + * res.json({ message: 'Hello from my plugin!' }); + * }); + * }, + * + * events: (on) => [ + * on.onDiscord('messageCreate', (msg) => { + * console.log('New message:', msg.content); + * }), + * on.onCustom('other-plugin:event', (data) => { + * console.log('Received:', data); + * }), + * ], + * + * onLoad: (ctx) => { + * ctx.logger.info('Plugin loaded!'); + * }, + * }); + * ``` + */ +export function definePlugin(definition: PluginDefinition): PluginDefinition { + // Validate required fields + if (!definition.metadata?.name || !definition.metadata?.version) { + throw new Error('Plugin must have metadata with name and version'); + } + + return definition; +} + +/** + * Standard API response format for plugin endpoints + */ +export interface PluginApiResponse { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +/** + * Create a successful API response + */ +export function success(data: T): PluginApiResponse { + return { success: true, data }; +} + +/** + * Create an error API response + */ +export function error(message: string, code?: string): PluginApiResponse { + return { success: false, error: message, code }; +} + +/** + * Validate request body against required fields + */ +export function validateBody>( + body: unknown, + requiredFields: (keyof T)[] +): body is T { + if (typeof body !== 'object' || body === null) { + return false; + } + const obj = body as Record; + return requiredFields.every((field) => field in obj); +} diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts new file mode 100644 index 0000000..6397af6 --- /dev/null +++ b/src/types/auth.types.ts @@ -0,0 +1,98 @@ +/** + * Available API scopes for granular access control. + */ +export type ApiScope = + | 'read:guilds' // Read guild information + | 'read:channels' // Read channel information + | 'read:members' // Read member information + | 'read:messages' // Read messages + | 'write:messages' // Send/edit/delete messages + | 'write:members' // Kick/ban/timeout members + | 'write:channels' // Create/edit/delete channels + | 'write:roles' // Create/edit/delete roles + | 'events' // Subscribe to WebSocket events + | 'admin'; // Full access (bypasses all checks) + +/** + * Represents a stored API key with its permissions. + * + * @security IMPORTANT: API Key Storage Security + * + * TODO: Before GA/production deployment, implement hashed key storage: + * + * 1. CURRENT STATE (Development Only): + * - Keys are stored in plaintext for development convenience + * - This is NOT secure for production use + * - If the key store is compromised, all keys are exposed + * + * 2. MIGRATION PLAN: + * a) Add bcrypt or argon2 dependency: `npm install argon2` (preferred) or `npm install bcrypt` + * b) On key creation: + * - Generate the raw key (e.g., "holo_abc123...") + * - Hash it: `const keyHash = await argon2.hash(rawKey)` + * - Store only `keyHash` in the database, return raw key to user ONCE + * - User must save the key; it cannot be recovered + * c) On key validation: + * - Receive raw key from request header + * - Load stored record by key prefix/ID + * - Verify: `await argon2.verify(storedHash, rawKey)` + * d) Add `keyPrefix` field (first 8 chars) for key lookup without exposing full key + * + * 3. RECOMMENDED FIELDS FOR PRODUCTION: + * - keyHash: string (argon2/bcrypt hash of the key) + * - keyPrefix: string (first 8 chars for identification, e.g., "holo_abc") + * - Remove: key field (never store plaintext) + * + * @example Production implementation + * ```typescript + * // Creation + * const rawKey = generateApiKey(); // "holo_abc123..." + * const keyHash = await argon2.hash(rawKey); + * const keyPrefix = rawKey.substring(0, 12); // "holo_abc123" + * await store.save({ id, name, keyHash, keyPrefix, scopes, createdAt }); + * return rawKey; // Return ONCE to user + * + * // Validation + * const record = await store.findByPrefix(keyPrefix); + * const isValid = await argon2.verify(record.keyHash, incomingKey); + * ``` + */ +export interface ApiKeyRecord { + /** Unique identifier for this key */ + id: string; + /** Human-readable name for the key */ + name: string; + /** + * The API key value + * @deprecated This stores the key in plaintext - FOR DEVELOPMENT ONLY + * @todo Replace with keyHash before production deployment + */ + key: string; + /** + * Hashed API key (for production use) + * @todo Implement hashed key validation before production + */ + keyHash?: string; + /** + * First 8-12 characters of the key for identification without exposing full key + * @todo Implement key lookup by prefix before production + */ + keyPrefix?: string; + /** Scopes granted to this key */ + scopes: ApiScope[]; + /** When this key was created */ + createdAt: Date; + /** When this key was last used */ + lastUsedAt?: Date; +} + +/** + * Extended Express Request with auth context. + */ +declare global { + namespace Express { + interface Request { + apiKey?: ApiKeyRecord; + } + } +} diff --git a/src/types/events.types.ts b/src/types/events.types.ts index e177a9d..c07a629 100644 --- a/src/types/events.types.ts +++ b/src/types/events.types.ts @@ -642,7 +642,7 @@ export interface InviteDeleteEvent { guildId: string | null; data: { code: string; - channelId: string; + channelId: string | null; guildId: string | null; }; } diff --git a/src/types/plugin.types.ts b/src/types/plugin.types.ts new file mode 100644 index 0000000..06132fb --- /dev/null +++ b/src/types/plugin.types.ts @@ -0,0 +1,129 @@ +import type { Client } from 'discord.js'; +import type { Server as SocketIOServer } from 'socket.io'; +import type { Application } from 'express'; +import type { Config } from '../config/index.js'; +import type { + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, +} from './events.types.js'; +import type { PluginEventBus, EventSubscription } from '../plugins/event-bus.js'; +import type { PluginRouter, PluginLogger } from '../plugins/sdk.js'; + +/** + * The context passed to plugins on load. + * Provides access to core HoloBridge services. + */ +export interface PluginContext { + /** The Discord.js client instance */ + client: Client; + /** The Socket.IO server for real-time events */ + io: SocketIOServer; + /** Application configuration */ + config: Config; + /** Express application (for advanced use cases) */ + app: Application; + /** Event bus for inter-plugin communication */ + eventBus: PluginEventBus; + /** Logger utility (legacy) */ + log: (message: string) => void; + /** Enhanced logger with levels */ + logger: PluginLogger; + /** Get metadata of a loaded plugin */ + getPlugin: (name: string) => PluginMetadata | undefined; + /** List all loaded plugin names */ + listPlugins: () => string[]; +} + +/** + * Metadata for a HoloBridge plugin. + */ +export interface PluginMetadata { + /** Unique plugin name */ + name: string; + /** Semantic version (e.g., "1.0.0") */ + version: string; + /** Plugin author */ + author?: string; + /** Short description */ + description?: string; +} + +/** + * The interface that all HoloBridge plugins must implement. + */ +export interface HoloPlugin { + /** Plugin metadata */ + metadata: PluginMetadata; + + /** + * Register REST API routes for this plugin. + * Routes will be mounted at /api/plugins/{plugin-name}/ + * + * @example + * ```typescript + * routes: (router, ctx) => { + * router.get('/status', (req, res) => { + * res.json({ status: 'ok' }); + * }); + * router.post('/action', (req, res) => { + * // Handle POST request + * }); + * } + * ``` + */ + routes?: (router: PluginRouter, ctx: PluginContext) => void; + + /** + * Set up event subscriptions for inter-plugin communication. + * Return an array of subscriptions for automatic cleanup on unload. + * + * @example + * ```typescript + * events: (helpers, ctx) => [ + * helpers.onDiscord('messageCreate', (msg) => { + * console.log('New message:', msg.content); + * }), + * helpers.onCustom('other-plugin:action', (data) => { + * // Handle custom event from another plugin + * }), + * ] + * ``` + */ + events?: ( + helpers: { + onDiscord: (event: string, handler: (data: T) => void | Promise) => EventSubscription; + onCustom: >(event: string, handler: (data: T) => void | Promise) => EventSubscription; + emit: >(event: string, data: T) => void; + onPluginLoaded: (handler: (data: { name: string; version: string }) => void) => EventSubscription; + onPluginUnloaded: (handler: (data: { name: string }) => void) => EventSubscription; + }, + ctx: PluginContext + ) => EventSubscription[]; + + /** + * Called when the plugin is loaded. + * Use this to set up event listeners, initialize state, etc. + */ + onLoad?: (ctx: PluginContext) => Promise | void; + + /** + * Called when the plugin is unloaded (e.g., on shutdown). + * Use this for cleanup. + */ + onUnload?: () => Promise | void; + + /** + * Called for every Discord event that HoloBridge broadcasts. + * @param eventName - The Discord event name (e.g., "messageCreate") + * @param data - The serialized event data + * @deprecated Use the `events` hook with typed subscriptions instead + */ + onEvent?: (eventName: string, data: unknown) => Promise | void; +} + +/** + * Type for plugin module exports. + */ +export type PluginExport = HoloPlugin | { default: HoloPlugin };