diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..0ba8230 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Dockerfile b/Dockerfile index 5d368f7..2ceea4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ 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 diff --git a/bin/holo.js b/bin/holo.js index 041e3f3..5562b4c 100644 --- a/bin/holo.js +++ b/bin/holo.js @@ -10,6 +10,7 @@ */ 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'; @@ -211,9 +212,11 @@ RATE_LIMIT_MAX=100 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(Math.floor(Math.random() * chars.length)); + key += chars.charAt(bytes[i] % charsLength); } return key; } @@ -246,24 +249,31 @@ Examples: const args = process.argv.slice(2); const command = args[0]; -switch (command) { - case 'start': - commandStart(args.slice(1)); - break; - case 'doctor': - commandDoctor(); - break; - case 'init': - commandInit(); - break; - case 'help': - case '--help': - case '-h': - case undefined: - showHelp(); - break; - default: - log(`Unknown command: ${command}`, 'red'); - showHelp(); +(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/plugins/README.md b/plugins/README.md index e384f50..3de6cfe 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -16,12 +16,24 @@ export default { description: 'What this plugin does', }, - onLoad(ctx) { - ctx.log('Plugin loaded!'); + // 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); + }), + ]; }, - onEvent(eventName, data) { - // React to Discord events + onLoad(ctx) { + ctx.logger.info('Plugin loaded!'); }, }; ``` @@ -32,14 +44,80 @@ export default { ```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 + 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`); + }), + ]; } ``` -### Lifecycle Hooks (Optional) +**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)` @@ -47,12 +125,9 @@ Called when the plugin is loaded at server startup. ```javascript onLoad(ctx) { - // Initialize your plugin - ctx.log('Hello from my plugin!'); - - // Access Discord.js client - const guildCount = ctx.client.guilds.cache.size; - ctx.log(`Connected to ${guildCount} guilds`); + 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(', ')}`); } ``` @@ -66,9 +141,9 @@ onUnload() { } ``` -#### `onEvent(eventName, data)` +#### `onEvent(eventName, data)` (Legacy) -Called for every Discord event that HoloBridge broadcasts. +> **Deprecated**: Use `events()` hook instead for typed subscriptions. ```javascript onEvent(eventName, data) { @@ -80,37 +155,88 @@ onEvent(eventName, data) { ## Plugin Context -The `ctx` object passed to `onLoad` provides: +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 | -| `log` | `(msg: string) => void` | Prefixed logger | +| `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 +### Auto-Responder with API ```javascript export default { metadata: { name: 'auto-responder', version: '1.0.0' }, - onLoad(ctx) { - this.client = ctx.client; - }, + triggers: new Map(), - async onEvent(eventName, data) { - if (eventName !== 'messageCreate') return; - if (data.author?.bot) return; + routes(router) { + router.get('/triggers', (req, res) => { + res.json({ success: true, data: Object.fromEntries(this.triggers) }); + }); - if (data.content === '!hello') { - const channel = await this.client.channels.fetch(data.channelId); - if (channel?.isTextBased()) { - await channel.send('Hello there!'); - } - } + 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); + } + } + }), + ]; }, }; ``` @@ -121,38 +247,61 @@ export default { export default { metadata: { name: 'event-logger', version: '1.0.0' }, - onEvent(eventName, data) { - console.log(`[${new Date().toISOString()}] ${eventName}`); + events(on) { + const eventTypes = ['messageCreate', 'guildMemberAdd', 'roleCreate']; + return eventTypes.map(event => + on.onDiscord(event, () => { + console.log(`[${new Date().toISOString()}] ${event}`); + }) + ); }, }; ``` -### Custom WebSocket Events +### Cross-Plugin Communication ```javascript +// Plugin A: Emits events export default { - metadata: { name: 'custom-events', version: '1.0.0' }, + 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, + }); + }), + ]; + }, +}; - onLoad(ctx) { - // Emit custom events to connected clients - setInterval(() => { - ctx.io.emit('custom:heartbeat', { time: Date.now() }); - }, 30000); +// 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 async/await** - Keep the event loop responsive -2. **Handle errors** - Wrap logic in try-catch blocks -3. **Clean up** - Use `onUnload` to close connections -4. **Be selective** - Filter events early in `onEvent` -5. **Log sparingly** - Avoid flooding the console +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 index c03e86c..12dc7ab 100644 --- a/plugins/example-plugin.js +++ b/plugins/example-plugin.js @@ -1,40 +1,110 @@ /** * Example HoloBridge Plugin * - * This plugin demonstrates the plugin system capabilities. - * It logs events and provides a simple "ping" response. + * 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: '1.0.0', + version: '2.0.0', author: 'HoloBridge', - description: 'An example plugin that logs events and responds to "!ping"', + description: 'An example plugin demonstrating events and REST endpoints', }, - onLoad(ctx) { - ctx.log('Example plugin loaded!'); - ctx.log(`Connected to ${ctx.client.guilds.cache.size} guild(s)`); - }, + /** + * 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(), + }, + }); + }); - onUnload() { - console.log('[Example Plugin] Goodbye!'); + // 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' }); + }); }, - async onEvent(eventName, data) { - // Only process messageCreate events - if (eventName !== 'messageCreate') return; + /** + * 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}`); + } + }), - const message = data; + // 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, + }); + }), - // Ignore bot messages - if (message.author?.bot) return; + // Listen for events from other plugins + on.onCustom('other-plugin:action', (data) => { + ctx.logger.debug('Received event from another plugin:', data); + }), - // Simple ping command - if (message.content === '!ping') { - console.log(`[Example Plugin] Received ping from ${message.author.username}`); - // Note: To respond, you would use the REST API or Discord.js client - } + // 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 a331be8..c7ceb29 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -20,7 +20,7 @@ function findApiKey(key: string): ApiKeyRecord | null { return { ...found, scopes: found.scopes as ApiScope[], - createdAt: new Date(), + createdAt: found.createdAt || new Date(), }; } diff --git a/src/api/middleware/rateLimit.ts b/src/api/middleware/rateLimit.ts index a586171..71cd672 100644 --- a/src/api/middleware/rateLimit.ts +++ b/src/api/middleware/rateLimit.ts @@ -24,17 +24,6 @@ function getClientId(req: Request): string { return req.ip ?? 'unknown'; } -/** - * Clean up expired entries periodically. - */ -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of rateLimitStore) { - if (entry.resetAt < now) { - rateLimitStore.delete(key); - } - } -}, 60000); // Clean up every minute /** * Global rate limiter middleware. @@ -90,10 +79,28 @@ export function rateLimiter(): RequestHandler { * 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): RequestHandler { +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(); @@ -125,3 +132,32 @@ export function strictRateLimiter(maxRequests: number, windowMs: number): Reques 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 index 83c16bd..5334228 100644 --- a/src/api/routes/automod.ts +++ b/src/api/routes/automod.ts @@ -1,86 +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, res) => { - const { guildId } = req.params as any; - const rules = await autoModService.getAutoModRules(guildId as string); - const response: ApiResponse = { success: true, data: rules }; - res.json(response); +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, res) => { - const { guildId, ruleId } = req.params as any; - const rule = await autoModService.getAutoModRule(guildId as string, ruleId as string); +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; - } + 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); + 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, res) => { - const { guildId } = req.params as any; - const rule = await autoModService.createAutoModRule(guildId as string, req.body); +router.post('/rules', async (req: Request, res) => { + try { + const { guildId } = req.params; - if (!rule) { - res.status(400).json({ success: false, error: 'Failed to create rule', code: 'RULE_CREATE_FAILED' }); - return; - } + // 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); + 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, res) => { - const { guildId, ruleId } = req.params as any; - const rule = await autoModService.editAutoModRule(guildId as string, ruleId as string, req.body); +router.patch('/rules/:ruleId', async (req: Request, res) => { + try { + const { guildId, ruleId } = req.params; - if (!rule) { - res.status(404).json({ success: false, error: 'Rule not found or failed to update', code: 'RULE_UPDATE_FAILED' }); - return; - } + // 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); + 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, res) => { - const { guildId, ruleId } = req.params as any; - const success = await autoModService.deleteAutoModRule(guildId as string, ruleId as string); +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; - } + 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 }); + 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 index d8bf17a..edcc7ef 100644 --- a/src/api/routes/emojis.ts +++ b/src/api/routes/emojis.ts @@ -1,86 +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, res) => { - const { guildId } = req.params as any; - const emojis = await emojiService.getGuildEmojis(guildId as string); - const response: ApiResponse = { success: true, data: emojis }; - res.json(response); +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, res) => { - const { guildId, emojiId } = req.params as any; - const emoji = await emojiService.getEmoji(guildId as string, emojiId as string); +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; - } + 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); + 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, res) => { - const { guildId } = req.params as any; - const emoji = await emojiService.createEmoji(guildId as string, req.body); +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; - } + 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); + 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, res) => { - const { guildId, emojiId } = req.params as any; - const emoji = await emojiService.editEmoji(guildId as string, emojiId as string, req.body); +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; - } + 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); + 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, res) => { - const { guildId, emojiId } = req.params as any; - const success = await emojiService.deleteEmoji(guildId as string, emojiId as string); +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; - } + 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 }); + 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 index 44d2938..647285a 100644 --- a/src/api/routes/invites.ts +++ b/src/api/routes/invites.ts @@ -10,16 +10,28 @@ const router = Router(); * Get an invite by code */ router.get('/:code', async (req, res) => { - const { code } = req.params; - const invite = await inviteService.getInvite(code); + 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' }); + 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; } - - const response: ApiResponse = { success: true, data: invite }; - res.json(response); }); /** @@ -27,15 +39,27 @@ router.get('/:code', async (req, res) => { * Delete an invite */ router.delete('/:code', async (req, res) => { - const { code } = req.params; - const success = await inviteService.deleteInvite(code); + 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' }); + 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; } - - res.json({ success: true }); }); export default router; diff --git a/src/api/routes/scheduled-events.ts b/src/api/routes/scheduled-events.ts index 9f09da1..a0ebf07 100644 --- a/src/api/routes/scheduled-events.ts +++ b/src/api/routes/scheduled-events.ts @@ -1,97 +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, res) => { - const { guildId } = req.params as any; - const events = await scheduledEventService.getGuildEvents(guildId as string); - const response: ApiResponse = { success: true, data: events }; - res.json(response); +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, res) => { - const { guildId, eventId } = req.params as any; - const event = await scheduledEventService.getEvent(guildId as string, eventId as string); - - if (!event) { - res.status(404).json({ success: false, error: 'Event not found', code: 'EVENT_NOT_FOUND' }); - return; +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' }); } - - const response: ApiResponse = { success: true, data: event }; - res.json(response); }); /** * POST /api/guilds/:guildId/scheduled-events * Create a new scheduled event */ -router.post('/', async (req, res) => { - const { guildId } = req.params as any; - const event = await scheduledEventService.createEvent(guildId as string, req.body); - - if (!event) { - res.status(400).json({ success: false, error: 'Failed to create event', code: 'EVENT_CREATE_FAILED' }); - return; +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' }); } - - const response: ApiResponse = { success: true, data: event }; - res.status(201).json(response); }); /** * PATCH /api/guilds/:guildId/scheduled-events/:eventId * Edit a scheduled event */ -router.patch('/:eventId', async (req, res) => { - const { guildId, eventId } = req.params as any; - const event = await scheduledEventService.editEvent(guildId as string, eventId as string, req.body); - - if (!event) { - res.status(404).json({ success: false, error: 'Event not found or failed to update', code: 'EVENT_UPDATE_FAILED' }); - return; +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' }); } - - const response: ApiResponse = { success: true, data: event }; - res.json(response); }); /** * DELETE /api/guilds/:guildId/scheduled-events/:eventId * Delete a scheduled event */ -router.delete('/:eventId', async (req, res) => { - const { guildId, eventId } = req.params as any; - const success = await scheduledEventService.deleteEvent(guildId as string, eventId as string); - - if (!success) { - res.status(404).json({ success: false, error: 'Event not found or failed to delete', code: 'EVENT_DELETE_FAILED' }); - return; +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' }); } - - res.json({ success: true }); }); /** * GET /api/guilds/:guildId/scheduled-events/:eventId/users * Get users subscribed to an event */ -router.get('/:eventId/users', async (req, res) => { - const { guildId, eventId } = req.params as any; - const users = await scheduledEventService.getEventUsers(guildId as string, eventId as string); - const response: ApiResponse = { success: true, data: users }; - res.json(response); +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 index e3e1130..5f870d4 100644 --- a/src/api/routes/stage-instances.ts +++ b/src/api/routes/stage-instances.ts @@ -11,15 +11,21 @@ const router = Router(); */ router.get('/:channelId', async (req, res) => { const { channelId } = req.params; - 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; - } + 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); + 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' }); + } }); /** @@ -34,15 +40,20 @@ router.post('/', async (req, res) => { return; } - const stageInstance = await stageInstanceService.createStageInstance(channelId, topic, options); + 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; - } + 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); + 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' }); + } }); /** @@ -51,15 +62,36 @@ router.post('/', async (req, res) => { */ router.patch('/:channelId', async (req, res) => { const { channelId } = req.params; - const stageInstance = await stageInstanceService.editStageInstance(channelId, req.body); - if (!stageInstance) { - res.status(404).json({ success: false, error: 'Stage instance not found or failed to update', code: 'STAGE_INSTANCE_UPDATE_FAILED' }); + // 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; } - const response: ApiResponse = { success: true, data: stageInstance }; - res.json(response); + 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' }); + } }); /** @@ -68,14 +100,20 @@ router.patch('/:channelId', async (req, res) => { */ router.delete('/:channelId', async (req, res) => { const { channelId } = req.params; - 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; - } + try { + const success = await stageInstanceService.deleteStageInstance(channelId); - res.json({ success: true }); + 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 index 6d2cc2a..5b9a1e1 100644 --- a/src/api/routes/stickers.ts +++ b/src/api/routes/stickers.ts @@ -10,10 +10,15 @@ const router = Router({ mergeParams: true }); * List all stickers in a guild */ router.get('/', async (req, res) => { - const { guildId } = req.params as any; - const stickers = await stickerService.getGuildStickers(guildId as string); - const response: ApiResponse = { success: true, data: stickers }; - res.json(response); + 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' }); + } }); /** @@ -21,16 +26,21 @@ router.get('/', async (req, res) => { * Get a specific sticker */ router.get('/:stickerId', async (req, res) => { - const { guildId, stickerId } = req.params as any; - const sticker = await stickerService.getSticker(guildId as string, stickerId as string); + 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; - } + 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); + 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' }); + } }); /** @@ -38,16 +48,21 @@ router.get('/:stickerId', async (req, res) => { * Create a new sticker */ router.post('/', async (req, res) => { - const { guildId } = req.params as any; - const sticker = await stickerService.createSticker(guildId as string, req.body); + 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; - } + 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); + 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' }); + } }); /** @@ -55,16 +70,21 @@ router.post('/', async (req, res) => { * Edit a sticker */ router.patch('/:stickerId', async (req, res) => { - const { guildId, stickerId } = req.params as any; - const sticker = await stickerService.editSticker(guildId as string, stickerId as string, req.body); + 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; - } + 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); + 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' }); + } }); /** @@ -72,15 +92,20 @@ router.patch('/:stickerId', async (req, res) => { * Delete a sticker */ router.delete('/:stickerId', async (req, res) => { - const { guildId, stickerId } = req.params as any; - const success = await stickerService.deleteSticker(guildId as string, stickerId as string); + 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; - } + 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 }); + 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 index f1615de..5213a4a 100644 --- a/src/api/routes/webhooks.ts +++ b/src/api/routes/webhooks.ts @@ -10,16 +10,22 @@ const router = Router(); * Get a specific webhook */ router.get('/:webhookId', async (req, res) => { - const { webhookId } = req.params; - const webhook = await webhookService.getWebhook(webhookId); + 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' }); + 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; } - - const response: ApiResponse = { success: true, data: webhook }; - res.json(response); }); /** @@ -27,16 +33,22 @@ router.get('/:webhookId', async (req, res) => { * Edit a webhook */ router.patch('/:webhookId', async (req, res) => { - const { webhookId } = req.params; - const webhook = await webhookService.editWebhook(webhookId, req.body); + 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; + } - if (!webhook) { - res.status(404).json({ success: false, error: 'Webhook not found or failed to update', code: 'WEBHOOK_UPDATE_FAILED' }); + 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; } - - const response: ApiResponse = { success: true, data: webhook }; - res.json(response); }); /** @@ -44,15 +56,21 @@ router.patch('/:webhookId', async (req, res) => { * Delete a webhook */ router.delete('/:webhookId', async (req, res) => { - const { webhookId } = req.params; - const success = await webhookService.deleteWebhook(webhookId); + 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; + } - if (!success) { - res.status(404).json({ success: false, error: 'Webhook not found or failed to delete', code: 'WEBHOOK_DELETE_FAILED' }); + 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; } - - res.json({ success: true }); }); export default router; diff --git a/src/api/server.ts b/src/api/server.ts index fce75a6..30d63d6 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -27,6 +27,7 @@ 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'; @@ -94,6 +95,9 @@ export function createApiServer(): ApiServerInstance { 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); app.use(errorHandler); @@ -120,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 5a2965f..b05f6f0 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,7 @@ const apiKeySchema = z.object({ name: z.string(), key: z.string(), scopes: z.array(z.string()), + createdAt: z.coerce.date().optional(), }); const configSchema = z.object({ @@ -73,16 +74,34 @@ function loadConfig(): Config { } /** - * Parse API_KEYS environment variable (JSON array) + * 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 { - return JSON.parse(envVar); + parsed = JSON.parse(envVar); } catch { - console.warn('⚠️ Failed to parse API_KEYS env var. Using empty array.'); + 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/services/automod.service.ts b/src/discord/services/automod.service.ts index fb0e6d5..98821f3 100644 --- a/src/discord/services/automod.service.ts +++ b/src/discord/services/automod.service.ts @@ -11,8 +11,13 @@ export class AutoModService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return []; - const rules = await guild.autoModerationRules.fetch(); - return rules.map(serializeAutoModRule); + 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 []; + } } /** @@ -37,8 +42,13 @@ export class AutoModService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return null; - const rule = await guild.autoModerationRules.create(data); - return serializeAutoModRule(rule); + 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; + } } /** diff --git a/src/discord/services/emoji.service.ts b/src/discord/services/emoji.service.ts index aa84152..12f2419 100644 --- a/src/discord/services/emoji.service.ts +++ b/src/discord/services/emoji.service.ts @@ -11,8 +11,13 @@ export class EmojiService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return []; - const emojis = await guild.emojis.fetch(); - return emojis.map(serializeGuildEmoji); + try { + const emojis = await guild.emojis.fetch(); + return emojis.map(serializeGuildEmoji); + } catch (error) { + console.error(`Failed to fetch emojis for guild ${guildId}:`, error); + return []; + } } /** @@ -37,8 +42,13 @@ export class EmojiService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return null; - const emoji = await guild.emojis.create(data); - return serializeGuildEmoji(emoji); + 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; + } } /** diff --git a/src/discord/services/scheduled-event.service.ts b/src/discord/services/scheduled-event.service.ts index 59ad7e8..a141af6 100644 --- a/src/discord/services/scheduled-event.service.ts +++ b/src/discord/services/scheduled-event.service.ts @@ -11,8 +11,13 @@ export class ScheduledEventService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return []; - const events = await guild.scheduledEvents.fetch(); - return events.map(serializeScheduledEvent); + 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 []; + } } /** @@ -37,8 +42,13 @@ export class ScheduledEventService { const guild = discordClient.guilds.cache.get(guildId); if (!guild) return null; - const event = await guild.scheduledEvents.create(data); - return serializeScheduledEvent(event); + 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; + } } /** diff --git a/src/discord/services/stage-instance.service.ts b/src/discord/services/stage-instance.service.ts index ee8bb55..105dc97 100644 --- a/src/discord/services/stage-instance.service.ts +++ b/src/discord/services/stage-instance.service.ts @@ -1,7 +1,8 @@ +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'; -import type { StageInstanceCreateOptions, StageInstanceEditOptions } from 'discord.js'; export class StageInstanceService { /** @@ -26,11 +27,19 @@ export class StageInstanceService { const channel = discordClient.channels.cache.get(channelId); if (!channel || !channel.isVoiceBased() || !channel.guild) return null; - const stageInstance = await channel.guild.stageInstances.create(channel as any, { - topic, - ...options - }); - return serializeStageInstance(stageInstance); + // 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; + } } /** diff --git a/src/discord/services/webhook.service.ts b/src/discord/services/webhook.service.ts index 466fc58..a1e31dd 100644 --- a/src/discord/services/webhook.service.ts +++ b/src/discord/services/webhook.service.ts @@ -67,7 +67,17 @@ export class WebhookService { async editWebhook(webhookId: string, data: { name?: string; avatar?: string | null; channelId?: string }): Promise { try { const webhook = await discordClient.fetchWebhook(webhookId); - const updated = await webhook.edit(data); + + // 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; diff --git a/src/index.ts b/src/index.ts index 0784296..61c7724 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,12 +19,12 @@ async function main(): Promise { console.log(''); // Create API server (needed for plugin context) - const { io } = createApiServer(); + const { io, app } = createApiServer(); // Initialize and load plugins if (config.plugins.enabled) { console.log('🔌 Initializing plugin system...'); - pluginManager.setContext(discordClient, io, config); + pluginManager.setContext(discordClient, io, config, app); await pluginManager.loadPlugins(); console.log(''); } 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 index 530f59a..746804e 100644 --- a/src/plugins/manager.ts +++ b/src/plugins/manager.ts @@ -2,6 +2,8 @@ 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'; @@ -9,6 +11,7 @@ import type { HoloPlugin, PluginContext, PluginExport, + PluginMetadata, } from '../types/plugin.types.js'; import type { ServerToClientEvents, @@ -16,17 +19,38 @@ import type { 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 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; } /** @@ -35,14 +59,33 @@ export class PluginManager { setContext( client: Client, io: SocketIOServer, - config: Config + 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; } /** @@ -106,12 +149,101 @@ export class PluginManager { 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(this.context); + await plugin.onLoad(pluginContext); } - this.plugins.set(name, plugin); + // 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}`); } @@ -119,12 +251,20 @@ export class PluginManager { * Emit an event to all loaded plugins. */ async emit(eventName: string, data: unknown): Promise { - for (const [name, plugin] of this.plugins) { + // 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)), + }); } } } @@ -134,7 +274,13 @@ export class PluginManager { * Unload all plugins (call onUnload handlers). */ async unloadAll(): Promise { - for (const [name, plugin] of this.plugins) { + 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(); @@ -143,6 +289,9 @@ export class PluginManager { console.error(`❌ Failed to unload plugin "${name}":`, error); } } + + // Emit plugin unloaded event + pluginEventBus.emitPlugin('plugin:unloaded', { name }); } this.plugins.clear(); } 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 index 7a37c4d..6397af6 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -15,14 +15,69 @@ export type ApiScope = /** * 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 (stored as-is for now, could be hashed) */ + /** + * 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 */ diff --git a/src/types/plugin.types.ts b/src/types/plugin.types.ts index d9617dd..06132fb 100644 --- a/src/types/plugin.types.ts +++ b/src/types/plugin.types.ts @@ -1,5 +1,6 @@ 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, @@ -7,6 +8,8 @@ import type { 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. @@ -19,8 +22,18 @@ export interface PluginContext { io: SocketIOServer; /** Application configuration */ config: Config; - /** Logger utility */ + /** 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[]; } /** @@ -44,6 +57,51 @@ 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. @@ -60,6 +118,7 @@ export interface HoloPlugin { * 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; }