UNPKG

hubot

Version:

A simple helpful robot for your Company

862 lines (717 loc) 23.6 kB
import { EventEmitter } from 'node:events' import fs from 'node:fs' import path from 'node:path' /** * CommandBus provides deterministic command handling for Hubot with safe-by-default behavior. * * Logging Strategy: * - Event logging to disk is disabled by default * - To enable: pass `disableLogging: false` in constructor options * - When enabled, events are written asynchronously (fire-and-forget) to avoid blocking * - Writes happen individually as events occur */ export class CommandBus extends EventEmitter { constructor(robot, options = {}) { super() this.robot = robot this.commands = new Map() this.pendingProposals = new Map() this.typeResolvers = new Map() this.prefix = options.prefix ?? '' this.proposalTTL = options.proposalTTL || 300000 // 5 minutes default this.logPath = options.logPath || path.join(process.cwd(), '.data', 'commands-events.ndjson') this.disableLogging = options.disableLogging ?? true this.permissionProvider = options.permissionProvider || null } register(spec, opts = {}) { if (!spec.id) { throw new Error('Command spec must have an id') } if (!spec.handler || typeof spec.handler !== 'function') { throw new Error('Command spec must have a handler function') } const aliases = this._normalizeAliases(spec.aliases) const existing = this.commands.get(spec.id) if (existing && !opts.update) { throw new Error(`Command ${spec.id} is already registered`) } const command = { id: spec.id, description: spec.description || '', aliases: aliases.original, normalizedAliases: aliases.normalized, examples: spec.examples || [], args: spec.args || {}, sideEffects: spec.sideEffects || [], confirm: spec.confirm || 'if_ambiguous', permissions: spec.permissions || {}, handler: spec.handler } this.commands.set(spec.id, command) const eventPayload = { commandId: spec.id, aliases: command.normalizedAliases, timestamp: Date.now() } if (existing && opts.update) { this.emit('commands:updated', eventPayload) this._log({ event: 'commands:updated', ...eventPayload }) } else { this.emit('commands:registered', eventPayload) this._log({ event: 'commands:registered', ...eventPayload }) } const collisions = this.aliasCollisions() if (Object.keys(collisions).length > 0) { this.emit('commands:alias_collision_detected', { collisions, timestamp: Date.now() }) this._log({ event: 'commands:alias_collision_detected', collisions, timestamp: Date.now() }) } return command } /** * Register a custom type resolver for argument validation. * Resolvers are called during validation and can transform/validate values. * * @param {string} typeName - The type name to register (e.g., 'project_id') * @param {Function} resolver - Async function(value, schema, context) that returns validated value or throws * @throws {Error} If typeName is empty or resolver is not a function * @public * * @example * robot.commands.registerTypeResolver('project_id', async (value, schema, context) => { * if (!value.startsWith('PRJ-')) throw new Error('must start with PRJ-') * return value.toUpperCase() * }) */ registerTypeResolver(typeName, resolver) { if (typeof typeName !== 'string' || !typeName) { throw new Error('Type name must be a non-empty string') } if (typeof resolver !== 'function') { throw new Error('Resolver must be a function') } this.typeResolvers.set(typeName, resolver) } unregister(commandId) { return this.commands.delete(commandId) } getCommand(commandId) { return this.commands.get(commandId) } listCommands(filter = {}) { let commands = Array.from(this.commands.values()) if (filter.prefix) { commands = commands.filter(c => c.id.startsWith(filter.prefix)) } return commands } aliasCollisions() { const collisions = {} const aliasMap = new Map() for (const command of this.commands.values()) { for (const alias of command.normalizedAliases || []) { if (!aliasMap.has(alias)) { aliasMap.set(alias, []) } aliasMap.get(alias).push(command.id) } } for (const [alias, ids] of aliasMap.entries()) { if (ids.length > 1) { collisions[alias] = ids } } return collisions } search(query, opts = {}) { if (!query || typeof query !== 'string') { return [] } const normalizedQuery = this._normalizeAlias(query) const queryTokens = this._tokenizeQuery(normalizedQuery) const results = [] for (const command of this.commands.values()) { const aliasMatches = this._scoreAliases(command, normalizedQuery, queryTokens) const descriptionMatches = this._scoreText(command.description, queryTokens) const exampleMatches = this._scoreExamples(command.examples, queryTokens) const bestAliasScore = aliasMatches.score const bestDescScore = descriptionMatches.score const bestExampleScore = exampleMatches.score const bestScore = Math.max(bestAliasScore, bestDescScore, bestExampleScore) if (bestScore === 0) { continue } let matchedOn = 'description' if (bestAliasScore >= bestDescScore && bestAliasScore >= bestExampleScore) { matchedOn = 'alias' } else if (bestExampleScore >= bestDescScore) { matchedOn = 'example' } results.push({ id: command.id, score: bestScore, matchedOn }) } results.sort((a, b) => b.score - a.score) return results } getHelp(commandId) { const command = this.getCommand(commandId) if (!command) { return null } let help = `Command: ${command.id}\n` help += `Description: ${command.description}\n` help += `Usage: ${this.prefix}${command.id} [options]\n` if (command.aliases.length > 0) { help += `Intent: ${command.aliases.join(', ')}\n` } if (Object.keys(command.args).length > 0) { help += '\nArguments:\n' for (const [name, schema] of Object.entries(command.args)) { const required = schema.required ? ' (required)' : '' const defaultVal = schema.default !== undefined ? ` [default: ${schema.default}]` : '' const values = schema.values ? ` [values: ${schema.values.join(', ')}]` : '' help += ` --${name} (${schema.type})${required}${defaultVal}${values}\n` } } if (command.examples.length > 0) { help += '\nExamples:\n' command.examples.forEach(ex => { help += ` ${ex}\n` }) } return help } parse(text) { if (!text || typeof text !== 'string') { return null } // Strip prefix if present (optional) const withoutPrefix = text.startsWith(this.prefix) ? text.slice(this.prefix.length).trim() : text.trim() const parts = this._tokenize(withoutPrefix) if (parts.length === 0) { return null } const commandId = parts[0] if (!this.commands.has(commandId)) { return null } const command = this.commands.get(commandId) const args = {} for (let i = 1; i < parts.length; i++) { const token = parts[i] // Handle -- key value pattern if (token === '--') { const key = parts[i + 1] const valueToken = parts[i + 2] if (key && valueToken && !valueToken.startsWith('--') && !valueToken.includes(':')) { args[key] = valueToken i += 2 } else if (key) { args[key] = true i += 1 } continue } // Handle --key value or --key "quoted value" if (token.startsWith('--')) { const key = token.slice(2) const nextToken = parts[i + 1] const schema = command.args[key] // Use schema hint: boolean type = flag, others expect value if (schema && schema.type === 'boolean') { args[key] = true } else if (nextToken && !nextToken.startsWith('--') && !nextToken.includes(':')) { args[key] = nextToken i++ // Skip next token } else { // No schema or ambiguous: default to boolean flag args[key] = true } } // Handle key:value or key:"quoted value" else if (token.includes(':')) { const colonIndex = token.indexOf(':') const key = token.slice(0, colonIndex) const value = token.slice(colonIndex + 1) args[key] = value } } const parsed = { commandId, args, rawText: text } this.emit('commands:invocation_parsed', { commandId, args, timestamp: Date.now() }) this._log({ event: 'commands:invocation_parsed', commandId, args, timestamp: Date.now() }) return parsed } _tokenize(text) { const tokens = [] let current = '' let inQuotes = false let quoteChar = null let escapeNext = false for (let i = 0; i < text.length; i++) { const char = text[i] if (escapeNext) { current += char escapeNext = false continue } if (inQuotes && char === '\\') { escapeNext = true continue } if ((char === '"' || char === '\'') && !inQuotes) { inQuotes = true quoteChar = char continue } if (char === quoteChar && inQuotes) { inQuotes = false quoteChar = null continue } if (char === ' ' && !inQuotes) { if (current) { tokens.push(current) current = '' } continue } current += char } if (current) { tokens.push(current) } return tokens } _normalizeAliases(aliases) { if (aliases === undefined || aliases === null) { return { original: [], normalized: [] } } if (!Array.isArray(aliases)) { throw new Error('Command aliases must be an array of strings') } const original = [] const normalized = [] const seen = new Set() for (const alias of aliases) { if (typeof alias !== 'string') { throw new Error('Command aliases must be an array of strings') } const trimmed = alias.trim() if (!trimmed) { throw new Error('Command aliases must be non-empty strings') } const normalizedAlias = this._normalizeAlias(trimmed) if (seen.has(normalizedAlias)) { continue } seen.add(normalizedAlias) original.push(trimmed) normalized.push(normalizedAlias) } return { original, normalized } } _normalizeAlias(alias) { return alias.trim().replace(/\s+/g, ' ').toLowerCase() } _tokenizeQuery(text) { return text.split(/\s+/).filter(Boolean) } _scoreAliases(command, normalizedQuery, queryTokens) { const aliases = command.normalizedAliases || [] if (aliases.length === 0) { return { score: 0 } } if (aliases.includes(normalizedQuery)) { return { score: 100 } } const bestOverlap = aliases.reduce((best, alias) => { const tokens = this._tokenizeQuery(alias) const overlap = queryTokens.filter(t => tokens.includes(t)).length return Math.max(best, overlap) }, 0) return { score: bestOverlap * 10 } } _scoreText(text, queryTokens) { if (!text) { return { score: 0 } } const tokens = this._tokenizeQuery(this._normalizeAlias(text)) const overlap = queryTokens.filter(t => tokens.includes(t)).length return { score: overlap * 5 } } _scoreExamples(examples, queryTokens) { if (!examples || examples.length === 0) { return { score: 0 } } const bestOverlap = examples.reduce((best, example) => { const tokens = this._tokenizeQuery(this._normalizeAlias(example)) const overlap = queryTokens.filter(t => tokens.includes(t)).length return Math.max(best, overlap) }, 0) return { score: bestOverlap * 5 } } async validate(commandId, rawArgs, context) { const command = this.getCommand(commandId) if (!command) { return { ok: false, errors: [`Command ${commandId} not found`], missing: [] } } const args = { ...rawArgs } const errors = [] const missing = [] // Apply defaults and validate each arg for (const [name, schema] of Object.entries(command.args)) { const value = args[name] // Check required if (schema.required && (value === undefined || value === null)) { missing.push(name) continue } // Apply default if (value === undefined && schema.default !== undefined) { args[name] = schema.default continue } // Skip validation if not provided and not required if (value === undefined) { continue } // Type validation and conversion try { args[name] = await this._validateType(name, value, schema, context) } catch (err) { errors.push(err.message) } } if (missing.length > 0 || errors.length > 0) { const result = { ok: false, errors, missing, args } this.emit('commands:validation_failed', { commandId, errors, missing, timestamp: Date.now() }) this._log({ event: 'commands:validation_failed', commandId, errors, missing, timestamp: Date.now() }) return result } return { ok: true, args } } async _validateType(name, value, schema, context) { // Check custom type resolvers first if (this.typeResolvers.has(schema.type)) { const resolver = this.typeResolvers.get(schema.type) try { return await resolver(value, schema, context) } catch (err) { throw new Error(`Argument ${name}: ${err.message}`) } } // Built-in types switch (schema.type) { case 'string': return String(value) case 'number': { const num = Number(value) if (isNaN(num)) { throw new Error(`Argument ${name} must be a number`) } return num } case 'boolean': { return coerceToBoolean(value) } case 'enum': { if (!Array.isArray(schema.values) || schema.values.length === 0) { throw new Error(`Argument ${name}: enum values must be a non-empty array`) } if (!schema.values.includes(value)) { throw new Error(`Argument ${name} must be one of: ${schema.values.join(', ')}`) } return value } case 'user': { const users = this.robot.brain.users() const user = Object.values(users).find(u => u.name === value || u.id === value) if (!user) { throw new Error(`Argument ${name}: user "${value}" not found`) } return user } case 'room': { if (!value.startsWith('#')) { throw new Error(`Argument ${name}: room must start with #`) } return value } case 'date': { let date if (value === 'today') { date = new Date() date.setHours(0, 0, 0, 0) } else if (value === 'tomorrow') { date = new Date() date.setDate(date.getDate() + 1) date.setHours(0, 0, 0, 0) } else { date = new Date(value) } if (isNaN(date.getTime())) { throw new Error(`Argument ${name}: invalid date "${value}"`) } return date } default: return value } } needsConfirmation(commandId) { const command = this.getCommand(commandId) if (!command) { return false } if (command.confirm === 'always') { return true } if (command.confirm === 'never') { return false } // Default: confirm if has side effects return command.sideEffects.length > 0 } async propose(proposal, context) { const { commandId, args } = proposal const command = this.getCommand(commandId) if (!command) { throw new Error(`Command ${commandId} not found`) } const confirmationKey = this._getConfirmationKey(context.user.id, context.room) const preview = this._renderPreview(commandId, args) const pendingProposal = { commandId, args, context, preview, confirmationKey, timestamp: Date.now(), timeoutId: null } this.pendingProposals.set(confirmationKey, pendingProposal) // Set TTL timeout pendingProposal.timeoutId = setTimeout(() => { this.pendingProposals.delete(confirmationKey) }, this.proposalTTL) this.emit('commands:proposal_created', { commandId, confirmationKey, timestamp: Date.now() }) this.emit('commands:proposal_confirm_requested', { commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_created', commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_confirm_requested', commandId, confirmationKey, timestamp: Date.now() }) return pendingProposal } async confirm(replyText, context) { const confirmationKey = this._getConfirmationKey(context.user.id, context.room) const pending = this.pendingProposals.get(confirmationKey) if (!pending) { return null } const normalizedReply = replyText.toLowerCase().trim() if (normalizedReply === 'yes' || normalizedReply === 'y') { clearTimeout(pending.timeoutId) this.pendingProposals.delete(confirmationKey) this.emit('commands:proposal_confirmed', { commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_confirmed', commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) const result = await this.execute(pending.commandId, pending.args, pending.context) return { executed: true, result } } if (normalizedReply === 'no' || normalizedReply === 'n' || normalizedReply === 'cancel') { clearTimeout(pending.timeoutId) this.pendingProposals.delete(confirmationKey) this.emit('commands:proposal_cancelled', { commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) this._log({ event: 'commands:proposal_cancelled', commandId: pending.commandId, confirmationKey, timestamp: Date.now() }) return { cancelled: true } } return null } async execute(commandId, args, context) { const command = this.getCommand(commandId) if (!command) { throw new Error(`Command ${commandId} not found`) } // Check permissions if (command.permissions.rooms && command.permissions.rooms.length > 0) { if (!command.permissions.rooms.includes(context.room)) { this.emit('commands:permission_denied', { commandId, room: context.room, timestamp: Date.now() }) this._log({ event: 'commands:permission_denied', commandId, room: context.room, timestamp: Date.now() }) throw new Error('Permission denied: command not allowed in this room') } } if (command.permissions.roles && command.permissions.roles.length > 0) { if (this.permissionProvider && typeof this.permissionProvider.hasRole === 'function') { const allowed = await this.permissionProvider.hasRole(context.user, command.permissions.roles, context) if (!allowed) { this.emit('commands:permission_denied', { commandId, roles: command.permissions.roles, timestamp: Date.now() }) this._log({ event: 'commands:permission_denied', commandId, roles: command.permissions.roles, timestamp: Date.now() }) throw new Error('Permission denied: insufficient role') } } } try { const result = await command.handler({ args, context }) this.emit('commands:executed', { commandId, timestamp: Date.now() }) this._log({ event: 'commands:executed', commandId, timestamp: Date.now() }) return result } catch (err) { this.emit('commands:error', { commandId, error: err.message, timestamp: Date.now() }) this._log({ event: 'commands:error', commandId, error: err.message, timestamp: Date.now() }) throw err } } async invoke(text, context) { const parsed = this.parse(text) if (!parsed) { return null } const helpRequested = parsed.args && (parsed.args.help === true || parsed.args.h === true) if (helpRequested) { const helpText = this.getHelp(parsed.commandId) return { ok: true, helpOnly: true, result: helpText } } const validation = await this.validate(parsed.commandId, parsed.args, context) if (!validation.ok) { return validation } // Check if needs confirmation if (this.needsConfirmation(parsed.commandId)) { const proposal = await this.propose({ commandId: parsed.commandId, args: validation.args }, context) return { needsConfirmation: true, proposal } } const result = await this.execute(parsed.commandId, validation.args, context) return { ok: true, result } } _getConfirmationKey(userId, room) { return `${userId}:${room}` } _renderPreview(commandId, args) { let preview = `${this.prefix}${commandId}` for (const [key, value] of Object.entries(args)) { const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value) const needsQuotes = valueStr.includes(' ') || valueStr.includes('"') const escapedValue = needsQuotes ? valueStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"') : valueStr preview += ` --${key} ${needsQuotes ? `"${escapedValue}"` : escapedValue}` } return preview } /** * Clear all pending proposal timers and proposals. * Call this during shutdown or in test teardown to prevent timers from keeping the process alive. * * @public */ clearPendingProposals() { for (const proposal of this.pendingProposals.values()) { if (proposal.timeoutId) { clearTimeout(proposal.timeoutId) } } this.pendingProposals.clear() } _log(event) { if (this.disableLogging) { return } // Fire and forget - write asynchronously without blocking this._writeLog(event).catch(() => {}) } async _writeLog(event) { try { const logDir = path.dirname(this.logPath) await fs.promises.mkdir(logDir, { recursive: true }) const line = JSON.stringify(event) + '\n' await fs.promises.appendFile(this.logPath, line, 'utf8') } catch (err) { // Silent fail for logging errors } } } function coerceToBoolean(value) { if (typeof value === 'boolean') { return value } if (typeof value === 'number') { return value !== 0 } if (typeof value === 'string') { const normalized = value.trim().toLowerCase() if (['true', 't', 'yes', 'y', '1', 'on'].includes(normalized)) { return true } if (['false', 'f', 'no', 'n', '0', 'off'].includes(normalized)) { return false } } return Boolean(value) }